@nxgt/janus-graphql 0.1.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +248 -0
  3. package/dist/context.d.ts +63 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/directives/apply.d.ts +46 -0
  6. package/dist/directives/apply.d.ts.map +1 -0
  7. package/dist/directives/guard.d.ts +21 -0
  8. package/dist/directives/guard.d.ts.map +1 -0
  9. package/dist/directives/permission/enforce.d.ts +24 -0
  10. package/dist/directives/permission/enforce.d.ts.map +1 -0
  11. package/dist/directives/permission/model.d.ts +35 -0
  12. package/dist/directives/permission/model.d.ts.map +1 -0
  13. package/dist/directives/permission/path.d.ts +31 -0
  14. package/dist/directives/permission/path.d.ts.map +1 -0
  15. package/dist/directives/permission/validate.d.ts +42 -0
  16. package/dist/directives/permission/validate.d.ts.map +1 -0
  17. package/dist/directives/read.d.ts +35 -0
  18. package/dist/directives/read.d.ts.map +1 -0
  19. package/dist/directives/validate.d.ts +33 -0
  20. package/dist/directives/validate.d.ts.map +1 -0
  21. package/dist/directives/words.d.ts +7 -0
  22. package/dist/directives/words.d.ts.map +1 -0
  23. package/dist/errors.d.ts +44 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/helpers.d.ts +56 -0
  26. package/dist/helpers.d.ts.map +1 -0
  27. package/dist/index.d.ts +27 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +645 -0
  30. package/dist/index.js.map +24 -0
  31. package/dist/memo.d.ts +16 -0
  32. package/dist/memo.d.ts.map +1 -0
  33. package/dist/plugin.d.ts +32 -0
  34. package/dist/plugin.d.ts.map +1 -0
  35. package/dist/sdl.d.ts +12 -0
  36. package/dist/sdl.d.ts.map +1 -0
  37. package/dist/types.d.ts +64 -0
  38. package/dist/types.d.ts.map +1 -0
  39. package/dist/wiring.d.ts +56 -0
  40. package/dist/wiring.d.ts.map +1 -0
  41. package/docs/README.md +18 -0
  42. package/docs/guide/context.md +156 -0
  43. package/docs/guide/directives.md +312 -0
  44. package/docs/guide/errors.md +138 -0
  45. package/docs/guide/federation.md +70 -0
  46. package/docs/guide/telemetry.md +45 -0
  47. package/docs/roadmap.md +59 -0
  48. package/docs/troubleshooting.md +471 -0
  49. package/graphql/janus.graphqls +36 -0
  50. package/package.json +71 -0
@@ -0,0 +1,312 @@
1
+ # Directives
2
+
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
5
+ are declared in `janusTypeDefs`, applied by `useJanus()` when the server
6
+ builds its schema, and checked before the resolver runs — a refused request
7
+ never reaches it.
8
+
9
+ ```ts
10
+ import { janusMaskError, janusTypeDefs, useJanus } from '@nxgt/janus-graphql';
11
+ import { createSchema, createYoga } from 'graphql-yoga';
12
+
13
+ export const yoga = createYoga({
14
+ schema: createSchema({ typeDefs: [janusTypeDefs, typeDefs], resolvers }),
15
+ plugins: [
16
+ useJanus({
17
+ auth,
18
+ access,
19
+ loaders: { record: (id) => records.find(id) }, // what @permission loads by id
20
+ conditions: { record: (record, ctx: Context) => ({ onShift: ctx.shift.open }) }, // a when()'s ctx
21
+ }),
22
+ ],
23
+ maskedErrors: { maskError: janusMaskError() },
24
+ });
25
+ ```
26
+
27
+ ## Where a directive applies
28
+
29
+ Both directives go on a field, an object type or an interface:
30
+
31
+ | Written on | Guards |
32
+ | --- | --- |
33
+ | a field | that field |
34
+ | an object type | every field of that type |
35
+ | an interface | every field of every object type implementing it |
36
+ | an interface's field | the field of the same name on every object type implementing it |
37
+
38
+ GraphQL runs resolvers on object types only, so a directive on an interface
39
+ is applied to each type implementing it, when the schema is built: nothing
40
+ an interface declares is left unguarded, and a type added later that
41
+ implements it is guarded too.
42
+
43
+ **Every directive that applies must hold** — the field's, its type's, its
44
+ interfaces'. A type's directive guards the type's fields, **not the field that
45
+ returns it**: `Query.ward` below runs for an anonymous request, and the
46
+ refusal lands on `ward.name`. Guard the field too when the lookup itself must
47
+ not run.
48
+
49
+ ## `@authenticated(type: [String!])`
50
+
51
+ ```graphql
52
+ type Query {
53
+ me: User @authenticated # any signed-in user
54
+ roster: [Staff!]! @authenticated(type: ["staff"])
55
+ ward(id: ID!): Ward
56
+ }
57
+
58
+ type Ward @authenticated(type: ["staff"]) { # every field of Ward
59
+ name: String
60
+ chart: String @authenticated(type: ["staff", "patient"])
61
+ }
62
+
63
+ interface Audited {
64
+ trail: String @authenticated(type: ["staff"]) # trail, on every type implementing Audited
65
+ }
66
+ ```
67
+
68
+ On `Ward.chart` above, the type's `["staff"]` and the field's
69
+ `["staff", "patient"]` both apply: only staff pass.
70
+
71
+ | The request | Answered |
72
+ | --- | --- |
73
+ | anonymous | `UNAUTHENTICATED`, 401 |
74
+ | a user of a type no `type:` names | `FORBIDDEN`, 403 |
75
+ | a store that cannot answer | `SERVICE_UNAVAILABLE`, 503 |
76
+ | a user every directive admits | the resolver runs |
77
+
78
+ Under `useJanus({ type: 'staff' })`, the only user type a directive may name
79
+ is `'staff'`.
80
+
81
+ ## `@permission(name, type, id, onDeny)`
82
+
83
+ ```graphql
84
+ directive @permission(
85
+ name: String!
86
+ type: String!
87
+ id: String
88
+ onDeny: JanusPermissionDenial! = NOT_FOUND
89
+ ) repeatable on OBJECT | INTERFACE | FIELD_DEFINITION
90
+ ```
91
+
92
+ Only a user holding permission `name` on the object of `type` whose id `id`
93
+ reads reaches the field. It is `@nxgt/janus-hono`'s
94
+ `permission(access, permission, type, load)` for a schema, and asks
95
+ `access.can` — the `permissions()` instance given to `useJanus()`.
96
+
97
+ | Argument | What it is |
98
+ | --- | --- |
99
+ | `name` | A relation or a permission `type` declares in the model — what `access.can` may be asked |
100
+ | `type` | An object type of the model |
101
+ | `id` | Where the object's id is read: `args.<path>` or `parent.<path>`. Absent: `args.id` on a field, `parent.id` on a type or an interface |
102
+ | `onDeny` | What a denial answers: `NOT_FOUND`, 404, by default, or `FORBIDDEN`, 403 |
103
+
104
+ `@permission` implies `@authenticated`: an anonymous request is answered
105
+ `UNAUTHENTICATED`, 401, before any object is loaded or any check asked.
106
+
107
+ ### Reading the id
108
+
109
+ ```graphql
110
+ type Query {
111
+ record(id: ID!): Record @permission(name: "view", type: "record") # args.id
112
+ transfer(input: TransferInput!): Transfer
113
+ @permission(name: "manage", type: "ward", id: "args.input.wardId") # a path into an input
114
+ }
115
+
116
+ type Record @permission(name: "view", type: "record") { # parent.id: the record itself
117
+ title: String
118
+ }
119
+
120
+ type Visit {
121
+ notes: String @permission(name: "enter", type: "ward", id: "parent.wardId") # another object, by id
122
+ }
123
+ ```
124
+
125
+ A path is `args` or `parent` followed by one or more names, each read from the
126
+ value before it. Where a step reads a list, the path goes on through every
127
+ element.
128
+
129
+ What `access.can` is given depends on where the id was read:
130
+
131
+ | The id | The object `access.can` is given |
132
+ | --- | --- |
133
+ | `parent.id`, or `parent.<path>.id` | **The value that holds it** — the parent, or the value the path reached — with `type` added, read through a proxy: a getter, a class's `#private` state or an ORM document's accessors answer as they do in the resolver |
134
+ | any other `parent.<path>`, or `args.<path>`, on a type with no `fromField` | `{ type, id }` |
135
+ | the same, on a type with a `fromField` | **What `loaders[type](id, ctx)` answers**, with `type` added — the `fromField`s read its fields. `null` is answered `NOT_FOUND` |
136
+
137
+ A `fromField` relation is read from the object's own data — a record's
138
+ `doctorId` — so the object must carry that field. The parent does when its
139
+ resolver answered the record; an id alone does not, and the loader is what
140
+ turns one into the object. A type with no `fromField` may have a loader too:
141
+ it is then asked for every id `@permission` reads alone — from `args`, or
142
+ from a parent field other than `id` — and its
143
+ `null` answers `NOT_FOUND` for an object that does not exist.
144
+
145
+ ```ts
146
+ useJanus({
147
+ auth,
148
+ access,
149
+ loaders: {
150
+ record: (id, ctx: Context) => records.find(id), // { id, doctorId, … } or null
151
+ },
152
+ });
153
+ ```
154
+
155
+ An id that holds `@`, `#`, a parenthesis, a NUL character or a lone
156
+ surrogate, or is empty, names no object: it is answered `NOT_FOUND` without
157
+ asking, and without calling the loader.
158
+
159
+ ### Lists
160
+
161
+ ```graphql
162
+ type Query {
163
+ records(ids: [ID!]!): [Record!]! @permission(name: "view", type: "record", id: "args.ids")
164
+ }
165
+
166
+ type Team {
167
+ wards: [Ward!]!
168
+ summary: String @permission(name: "enter", type: "ward", id: "parent.wards.id")
169
+ }
170
+ ```
171
+
172
+ **A list requires the permission on every element**: one denial denies the
173
+ field. The checks of a list are asked together. An empty list in `args` asks
174
+ nothing, and the resolver runs: the client asked for nothing. An empty list
175
+ in the parent is answered `NOT_FOUND`: the object's data names nothing to
176
+ check, and its fields are not open to every user for it. This is not a
177
+ filter: to answer only the items a user may see, ask `access.list()` for
178
+ their ids and load those.
179
+
180
+ ### Conditions
181
+
182
+ A permission whose rules reach a `when()` needs a `ctx`, as `access.can`
183
+ does. `conditions` answers it, per object type, from the object checked and
184
+ the request's context:
185
+
186
+ ```ts
187
+ // model: record.permits.edit = [when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)]
188
+ useJanus({
189
+ auth,
190
+ access,
191
+ loaders: { record: (id) => records.find(id) },
192
+ conditions: {
193
+ record: async (record, ctx: Context) => ({ onShift: await shifts.isOpen(ctx) }),
194
+ },
195
+ });
196
+ ```
197
+
198
+ One entry per type covers every permission of it: it answers every
199
+ condition any of them reaches, typed as the intersection of their `ctx`s.
200
+
201
+ ### Repeated, and in order
202
+
203
+ ```graphql
204
+ type Query {
205
+ discharge(id: ID!): Discharge
206
+ @permission(name: "view", type: "record")
207
+ @permission(name: "discharge", type: "record", onDeny: FORBIDDEN)
208
+ }
209
+ ```
210
+
211
+ **Every one must hold** — an either-or belongs in the model, as a
212
+ permission whose rules name both. They are asked **one at a time, in order**,
213
+ and the first that denies answers: the ones after it are never asked.
214
+
215
+ 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
217
+ `NOT_FOUND` answers before a field's `FORBIDDEN` could tell that the object
218
+ exists.
219
+
220
+ ### `NOT_FOUND` or `FORBIDDEN`
221
+
222
+ `NOT_FOUND` is the default, because a `FORBIDDEN` says *this exists, and not
223
+ for you*: for `record(id: "r42")` that is already an answer to a question the
224
+ user may not ask. Choose `onDeny: FORBIDDEN` where the object is known to
225
+ exist anyway — a field of an object the user may already see:
226
+
227
+ ```graphql
228
+ type Record @permission(name: "view", type: "record") {
229
+ title: String
230
+ billing: Billing @permission(name: "manage", type: "record", onDeny: FORBIDDEN)
231
+ }
232
+ ```
233
+
234
+ | The request | Answered |
235
+ | --- | --- |
236
+ | anonymous | `UNAUTHENTICATED`, 401 |
237
+ | a user of a type `@authenticated` does not name | `FORBIDDEN`, 403, before any check |
238
+ | no id at the path, an empty list in the parent, or an id no object can hold | `NOT_FOUND`, 404 |
239
+ | a loader answering `null` | `NOT_FOUND`, 404 |
240
+ | a denial | `onDeny`: `NOT_FOUND`, 404, or `FORBIDDEN`, 403 |
241
+ | a store — or a loader — that cannot answer | `SERVICE_UNAVAILABLE`, 503, never a denial |
242
+ | every permission held | the resolver runs |
243
+
244
+ A path that reads nothing — an optional argument left out, a `null` field of
245
+ the parent — is answered `NOT_FOUND`, and said once per directive through
246
+ `process.emitWarning`: `@permission on Query.record resolved no object id
247
+ from args.id — answered NOT_FOUND`.
248
+
249
+ ### One check per question, per request
250
+
251
+ The same question asked twice in one request is asked once: two fields —
252
+ aliased or not — a directive and a resolver's `can()`, or two fields resolved
253
+ at once, which share the one pending answer. The question is the notation
254
+ `record:r1#view@staff:u1`: object, permission and subject. Across requests
255
+ nothing is kept.
256
+
257
+ Not remembered: a check with a condition's `ctx`, which the question does
258
+ not hold; an anonymous one, which costs no store call; and a failure — the
259
+ next question asks again.
260
+
261
+ **A subscription's directives skip the memo.** It is one request whose
262
+ fields resolve again on every event, so each event's `@permission` asks
263
+ afresh and a `revoke()` made since the subscription started stops the next
264
+ event. `can(ctx, …)` called in a subscription's resolver still reads the
265
+ memo: call `ctx.janus.access.can` there. **A mutation does not**: a
266
+ mutation that grants or revokes and then asks the same question in the same
267
+ request reads the answer remembered before the change. Ask
268
+ `access.can` directly after such a change, or answer from the mutation's own
269
+ result.
270
+
271
+ ## Refused when the schema is built
272
+
273
+ What no request could ever pass is a `TypeError` at start-up, naming the
274
+ field, and the location the directive was written on when it is not the
275
+ field — `(read by Ward.name)`:
276
+
277
+ ```
278
+ TypeError: applyJanusDirectives(): @authenticated on Query.me names the user type 'doctor', which is not one of 'patient', 'staff'
279
+ TypeError: applyJanusDirectives(): @authenticated on Query.me names no user type — leave type: out to admit any signed-in user
280
+ TypeError: applyJanusDirectives(): the @authenticated on Ward.chart and on its type or interfaces admit no user type in common
281
+ TypeError: applyJanusDirectives(): @permission on Query.ward needs the permissions() instance — pass useJanus({ auth, access }), with access what permissions() answered
282
+ TypeError: applyJanusDirectives(): @permission on Query.invoice names the object type 'invoice', which is not one of 'record', 'ward'
283
+ TypeError: applyJanusDirectives(): @permission on Query.ward asks 'delete', which ward does not declare — it declares 'nurses', 'visitors', 'enter', 'manage'
284
+ TypeError: applyJanusDirectives(): @permission on Query.ward reads its id from 'ward.id', which is not args.<name> or parent.<name>
285
+ TypeError: applyJanusDirectives(): @permission on Query.ward reads args.wardId, and Query.ward takes no argument wardId
286
+ TypeError: applyJanusDirectives(): @permission on Query.record reads the record's id from args.id, and record reads 'doctorId' of the object itself (fromField) — pass useJanus({ loaders: { record: (id, ctx) => … } })
287
+ TypeError: applyJanusDirectives(): @permission on Query.record asks 'edit' of record, which reaches a when() — pass useJanus({ conditions: { record: (object, ctx) => … } })
288
+ TypeError: applyJanusDirectives(): @permission on Query.ward finds loaders.ward, which is not a function
289
+ ```
290
+
291
+ Each is in [troubleshooting](../troubleshooting.md), with its fix. The
292
+ compiler refuses the wiring's own mistakes before that: a loader for a type
293
+ the model does not declare, a loader answering an object without a field a
294
+ `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`.
296
+
297
+ ## The transform alone
298
+
299
+ `applyJanusDirectives(schema, { auth, type?, access?, loaders?, conditions? })`
300
+ is what `useJanus()` runs on every schema. Call it to check a schema at build
301
+ time — in a test, or in a script that fails a deploy — without starting a
302
+ server:
303
+
304
+ ```ts
305
+ import { applyJanusDirectives } from '@nxgt/janus-graphql';
306
+
307
+ applyJanusDirectives(schema, { auth, access, loaders, conditions }); // throws the TypeErrors above
308
+ ```
309
+
310
+ The guards it installs read `ctx.janus`, which only `useJanus()` builds: a
311
+ guarded field resolved under any other context throws
312
+ `TypeError: @permission on Query.ward: ctx.janus is not set — add useJanus({ auth }) to the plugins`.
@@ -0,0 +1,138 @@
1
+ # Errors
2
+
3
+ This page is for what a client receives when a request is refused or a store
4
+ is down, and how that becomes an HTTP status. When you have a message in
5
+ hand and want its cause, see [troubleshooting](../troubleshooting.md).
6
+
7
+ ## No error class of its own
8
+
9
+ This package throws `GraphQLError` for a denial, and lets `@nxgt/janus`'s own
10
+ errors through. Every error it answers carries a `code` and a status in its
11
+ `extensions`:
12
+
13
+ ```ts
14
+ new GraphQLError('Not signed in', {
15
+ extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },
16
+ });
17
+ ```
18
+
19
+ Yoga reads `extensions.http.status` to answer the HTTP response, and strips
20
+ `http` from the body, so the client sees:
21
+
22
+ ```json
23
+ {
24
+ "data": { "me": null },
25
+ "errors": [{ "message": "Not signed in", "path": ["me"], "extensions": { "code": "UNAUTHENTICATED" } }]
26
+ }
27
+ ```
28
+
29
+ ## The codes
30
+
31
+ | Code | Status | Message | When |
32
+ | --- | --- | --- | --- |
33
+ | `UNAUTHENTICATED` | 401 | `Not signed in` | An anonymous request reached a field `@authenticated` guards, or `requireUser()` |
34
+ | `FORBIDDEN` | 403 | `Forbidden` | A user of a type the directive or `requireUser({ type })` does not name, or a `@permission(onDeny: FORBIDDEN)` denied |
35
+ | `NOT_FOUND` | 404 | `Not found` | `@permission`'s default denial, a loader answering `null`, an id `@permission` could not read — and `denial('NOT_FOUND')` |
36
+ | `SERVICE_UNAVAILABLE` | 503 | `The service is unavailable, retry later` | A store could not answer: `STORE_FAILED` |
37
+ | 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, … |
38
+
39
+ The status of every `JanusErrorCode` is `@nxgt/janus`'s
40
+ [`statusOf`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md),
41
+ the table `@nxgt/janus-hono` answers with too.
42
+
43
+ What a refusal carries beyond `code` is only what the client can act on:
44
+ `issues` for `USER_INVALID`, `minLength` for `PASSWORD_TOO_SHORT`,
45
+ `attemptsLeft` for `CODE_INVALID`. **Never** `reason`, `login`, `slot`,
46
+ `operation`, a hash prefix or a cause — those are for your logs. **The
47
+ message is never the core's**: `@nxgt/janus`'s messages name a hash prefix or
48
+ a record's versions, so every `JanusError` gets the fixed message of its
49
+ status, and a client tells refusals apart by `code`.
50
+
51
+ ## An outage is never a denial
52
+
53
+ **An absence is `null`; a failure throws** — and here a failure is never
54
+ answered `UNAUTHENTICATED`, `FORBIDDEN` or `NOT_FOUND`. A 401 during an
55
+ outage tells every user they are signed out; a 403 tells them they lost their
56
+ rights; a 404 tells them the record is gone. All send them somewhere a retry
57
+ would not.
58
+
59
+ | Where the store fails | Answered |
60
+ | --- | --- |
61
+ | a field `@authenticated` or `@permission` guards | `SERVICE_UNAVAILABLE`, 503 — with or without `janusMaskError()` |
62
+ | a `loaders` entry throwing `StoreFailure` — or any `JanusError` — for `@permission` | the `JanusError`'s status: `SERVICE_UNAVAILABLE`, 503, for `STORE_FAILED` — with or without `janusMaskError()` |
63
+ | `requireUser(ctx)`, `can(ctx, …)` | `SERVICE_UNAVAILABLE`, 503 — with or without `janusMaskError()` |
64
+ | `ctx.janus.user()` read in a resolver, any `auth.*` or `access.*` call | `SERVICE_UNAVAILABLE`, 503 with `janusMaskError()`; Yoga's masked 500 without it |
65
+
66
+ ## `janusMaskError(fallback?)`
67
+
68
+ Yoga masks every error that is not a `GraphQLError` into `Unexpected error.`
69
+ with a 500. That is right for a bug and wrong for a `JanusError`: a wrong
70
+ password is a 401 the client must see, and an outage a 503 it must retry.
71
+
72
+ ```ts
73
+ import { janusMaskError } from '@nxgt/janus-graphql';
74
+ import { createYoga, maskError } from 'graphql-yoga';
75
+
76
+ createYoga({
77
+ schema,
78
+ plugins: [useJanus({ auth })],
79
+ maskedErrors: { maskError: janusMaskError(maskError) },
80
+ });
81
+ ```
82
+
83
+ A `JanusError` — thrown by a resolver, or wrapped by graphql-js with its path —
84
+ is answered as the table above says, at the path it was thrown from. Anything
85
+ else goes to `fallback`.
86
+
87
+ Without a `fallback`, a `GraphQLError` of your own is kept as it is, and
88
+ anything else becomes the mask's message with the code
89
+ `INTERNAL_SERVER_ERROR` and no detail. Pass Yoga's own `maskError`, as above,
90
+ to keep what it shows in development. The signature is envelop's too, so the
91
+ same function fits `useMaskedErrors({ maskError })`.
92
+
93
+ `janusGraphQLError(error)` is the conversion alone, for a resolver that
94
+ catches a `JanusError` to answer it itself:
95
+
96
+ ```ts
97
+ import { janusGraphQLError } from '@nxgt/janus-graphql';
98
+ import { JanusError } from '@nxgt/janus';
99
+
100
+ try {
101
+ return await auth.patient.signIn(input);
102
+ } catch (error) {
103
+ if (error instanceof JanusError) throw janusGraphQLError(error); // CREDENTIALS_INVALID → 401
104
+ throw error;
105
+ }
106
+ ```
107
+
108
+ ## The status of a response
109
+
110
+ Yoga answers **the highest status among the errors**. A query whose fields
111
+ are refused for different reasons is answered with the worst of them, and a
112
+ query where one field is refused and the others answered is still that
113
+ field's status:
114
+
115
+ | The query, anonymous | Status | `data` |
116
+ | --- | --- | --- |
117
+ | `{ open }` | 200 | `{ open }` |
118
+ | `{ me }` | 401 | `{ me: null }` |
119
+ | `{ open me }` | 401 | `{ open, me: null }` |
120
+
121
+ A client reads `data` and `errors`, not only the status.
122
+
123
+ ## Your own denials
124
+
125
+ `denial(code, message?)` is the `GraphQLError` the guards throw, for a
126
+ resolver that refuses on its own terms:
127
+
128
+ ```ts
129
+ import { denial } from '@nxgt/janus-graphql';
130
+
131
+ if (record.archived) throw denial('NOT_FOUND', 'No such record');
132
+ ```
133
+
134
+ | `code` | Status |
135
+ | --- | --- |
136
+ | `UNAUTHENTICATED` | 401 |
137
+ | `FORBIDDEN` | 403 |
138
+ | `NOT_FOUND` | 404 |
@@ -0,0 +1,70 @@
1
+ # Federation
2
+
3
+ This page is for a subgraph of a federated graph: where the directives are
4
+ checked, how the router must forward the request, and how to keep them in
5
+ the supergraph with `@composeDirective`.
6
+
7
+ ## Checked in the subgraph
8
+
9
+ `useJanus()` guards the schema of the server it is plugged into. In a
10
+ federated graph that is the subgraph: each subgraph that serves a guarded
11
+ field runs its own `useJanus({ auth, access })`, and checks the directive
12
+ when the router asks it for the field. Nothing is checked by the router.
13
+
14
+ So **the router must forward what `authenticate` reads**: the
15
+ `Authorization` header, `X-Session-Token`, or the session cookie. A router
16
+ that forwards none of them turns every signed-in user into an anonymous one,
17
+ answered `UNAUTHENTICATED` by the subgraph. Configure header propagation in
18
+ the router for the subgraphs that use this package.
19
+
20
+ An entity's fields are guarded as any object type's: a type-level
21
+ `@permission` reads `parent.id`, and the parent is what your
22
+ `__resolveReference` answered, so it carries the fields its `fromField`s
23
+ read when your reference resolver loads them.
24
+
25
+ ```graphql
26
+ type Record @key(fields: "id") @permission(name: "view", type: "record") {
27
+ id: ID!
28
+ title: String
29
+ }
30
+ ```
31
+
32
+ ## The shipped SDL
33
+
34
+ The directives and the `JanusPermissionDenial` enum ship twice, as the same text:
35
+ `janusTypeDefs`, and `graphql/janus.graphqls` in the package —
36
+ `node_modules/@nxgt/janus-graphql/graphql/janus.graphqls`. Composition
37
+ requires a directive to be declared identically in every subgraph that
38
+ composes it, and reading it from the package, rather than copying it, keeps
39
+ every subgraph on the definition of the version it installed.
40
+
41
+ A subgraph built with `@apollo/subgraph` or `@graphql-tools/federation`
42
+ takes `janusTypeDefs` among its type definitions, like any server; a
43
+ schema-first toolchain reads the file.
44
+
45
+ ## Keeping the directives in the supergraph
46
+
47
+ Composition drops a subgraph's own directives from the supergraph unless
48
+ they are composed. The checks do not need them there — they run in the
49
+ subgraph — but a client generator or a schema registry reading the
50
+ supergraph does, to show which fields are guarded. Link them under a
51
+ specification URL of your own and compose them:
52
+
53
+ ```graphql
54
+ extend schema
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"])
57
+ @composeDirective(name: "@authenticated")
58
+ @composeDirective(name: "@permission")
59
+ ```
60
+
61
+ Every subgraph that composes a directive must declare it identically, which
62
+ the shipped SDL does, and link it under the same URL, with the same major
63
+ version.
64
+
65
+ **Do not import federation's own `@authenticated`.** Federation 2.5 declares
66
+ a directive of that name, read by the router, and a subgraph that imports it
67
+ from the federation `@link` while declaring `janusTypeDefs` has two
68
+ definitions of one name. Import only what you use from the federation
69
+ specification — `@key`, `@composeDirective` — and let this package's
70
+ `@authenticated` be the one the subgraph checks.
@@ -0,0 +1,45 @@
1
+ # Telemetry
2
+
3
+ This page is for tracing what the GraphQL server asks of `@nxgt/janus`: a span
4
+ per authentication and per permission check, with
5
+ [`@nxgt/janus-telemetry`](https://www.npmjs.com/package/@nxgt/janus-telemetry).
6
+
7
+ `@nxgt/janus-graphql` does not depend on it, and has nothing to configure for
8
+ it: `useJanus()` takes the instances you give it, and an instrumented
9
+ instance answers exactly as the plain one does, with the same types.
10
+
11
+ ```ts
12
+ import { janus } from '@nxgt/janus';
13
+ import { permissions } from '@nxgt/janus/permissions';
14
+ import { useJanus } from '@nxgt/janus-graphql';
15
+ import { instrumentJanus, instrumentPermissions } from '@nxgt/janus-telemetry';
16
+
17
+ export const auth = instrumentJanus(janus({ users, hasher, ...stores }));
18
+ export const access = instrumentPermissions(permissions({ model, store }));
19
+
20
+ useJanus({ auth, access, loaders, conditions });
21
+ ```
22
+
23
+ `instrumentPermissions(access)` answers a frozen copy that keeps `model`, so
24
+ `@permission` reads the model from it when the schema is built, and every
25
+ check — a directive's or `can()`'s — goes through the traced `can`.
26
+
27
+ ## What shows up
28
+
29
+ | Span | Written for |
30
+ | --- | --- |
31
+ | `janus.authenticate` | The first `ctx.janus.user()` or `ctx.janus.session()` of a request — a guarded field asks it — and never when neither is asked. Once per request |
32
+ | `janus.can` | Each **distinct** permission check of a request: `@permission`'s and `can()`'s |
33
+
34
+ The spans nest under the server span of whatever opened one — an
35
+ OpenTelemetry instrumentation of your server, or `@nxgt/telemetry`'s own.
36
+
37
+ **A question answered from the request's memo writes no span.** The memo sits
38
+ between `ctx.janus` and `access.can`, so the same object, permission and user
39
+ asked twice in one request — two fields, or a directive and `can()` — is one
40
+ `janus.can`. A check with a condition's `ctx` is never remembered, and
41
+ writes one span each time.
42
+
43
+ **A loader is yours to trace.** `loaders[type](id, ctx)` runs your code; what
44
+ it asks of your database shows up as your database's instrumentation writes
45
+ it, not as a `janus.*` span.
@@ -0,0 +1,59 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/janus-graphql` is heading. A direction, not a commitment: there
4
+ are no dates here, and the version something shipped in is the only number.
5
+
6
+ ## Now
7
+
8
+ Nothing between releases.
9
+
10
+ ## Next
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 signed in less than `maxAge`
16
+ ago may resolve: a password change, a new e-mail, a payment method, asked
17
+ again of a session that is days old.
18
+ - **The renewed session cookie on GraphQL responses** — `authenticate`
19
+ renews a sliding session in passing; the response would then carry its
20
+ `Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
21
+ its own.
22
+
23
+ ## Later
24
+
25
+ Nothing yet.
26
+
27
+ ## Not planned
28
+
29
+ - **Filtering a list by permission** — no directive drops the items of a list
30
+ the user may not see. Loading every item and checking each is a query that
31
+ grows with the table; ask the question the other way round with
32
+ `access.list()`, which answers the ids the user holds a permission on, and
33
+ load only those.
34
+ - **Introspection control and rate limiting** — neither is about who the user
35
+ is. Envelop and Yoga have plugins for both; this package stays out of their
36
+ way.
37
+ - **Error classes of its own** — a denial is a `GraphQLError`, and anything
38
+ else is `@nxgt/janus`'s, which is why `@nxgt/janus` is a required peer and
39
+ never a dependency.
40
+ - **`moduleResolution: "nodenext"`** — like `@nxgt/janus`, the package
41
+ imports without extensions. Use `"moduleResolution": "bundler"`.
42
+
43
+ ## Shipped
44
+
45
+ Newest first; from the first release on, the package's CHANGELOG holds every one.
46
+
47
+ - **The first release, v0.1.0.** GraphQL for `@nxgt/janus`, as an envelop
48
+ plugin: `useJanus()` with a lazy `ctx.janus`, authenticated only when a
49
+ field asks; `@authenticated` on fields, types and interfaces, for any user
50
+ or some user types; `@permission(name, type, id, onDeny)`, reading the id
51
+ from `args.<path>` or `parent.<path>`, loading an object by id through
52
+ `loaders` and a condition's `ctx` through `conditions`, one check per
53
+ question per request, and a denial answered `NOT_FOUND` unless
54
+ `onDeny: FORBIDDEN`; `requireUser()` and `can()` for a resolver;
55
+ `janusMaskError()`, an outage as 503; and the SDL as `janusTypeDefs` and
56
+ `graphql/janus.graphqls`. What no request could pass is refused at
57
+ start-up, naming the field. The specs and the typecheck also run on the
58
+ peer floors — `graphql` 16.9.0, `@graphql-tools/utils` 10.0.0 and
59
+ `@envelop/core` 5.0.0 — in CI.