@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.
- package/LICENSE +21 -0
- package/README.md +248 -0
- package/dist/context.d.ts +63 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/directives/apply.d.ts +46 -0
- package/dist/directives/apply.d.ts.map +1 -0
- package/dist/directives/guard.d.ts +21 -0
- package/dist/directives/guard.d.ts.map +1 -0
- package/dist/directives/permission/enforce.d.ts +24 -0
- package/dist/directives/permission/enforce.d.ts.map +1 -0
- package/dist/directives/permission/model.d.ts +35 -0
- package/dist/directives/permission/model.d.ts.map +1 -0
- package/dist/directives/permission/path.d.ts +31 -0
- package/dist/directives/permission/path.d.ts.map +1 -0
- package/dist/directives/permission/validate.d.ts +42 -0
- package/dist/directives/permission/validate.d.ts.map +1 -0
- package/dist/directives/read.d.ts +35 -0
- package/dist/directives/read.d.ts.map +1 -0
- package/dist/directives/validate.d.ts +33 -0
- package/dist/directives/validate.d.ts.map +1 -0
- package/dist/directives/words.d.ts +7 -0
- package/dist/directives/words.d.ts.map +1 -0
- package/dist/errors.d.ts +44 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/helpers.d.ts +56 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +645 -0
- package/dist/index.js.map +24 -0
- package/dist/memo.d.ts +16 -0
- package/dist/memo.d.ts.map +1 -0
- package/dist/plugin.d.ts +32 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/sdl.d.ts +12 -0
- package/dist/sdl.d.ts.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/wiring.d.ts +56 -0
- package/dist/wiring.d.ts.map +1 -0
- package/docs/README.md +18 -0
- package/docs/guide/context.md +156 -0
- package/docs/guide/directives.md +312 -0
- package/docs/guide/errors.md +138 -0
- package/docs/guide/federation.md +70 -0
- package/docs/guide/telemetry.md +45 -0
- package/docs/roadmap.md +59 -0
- package/docs/troubleshooting.md +471 -0
- package/graphql/janus.graphqls +36 -0
- 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.
|
package/docs/roadmap.md
ADDED
|
@@ -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.
|