@nxgt/shared-graphql 2.1.0 → 3.0.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 (68) hide show
  1. package/README.md +112 -52
  2. package/dist/directives/authenticated.d.ts +42 -0
  3. package/dist/directives/authenticated.d.ts.map +1 -0
  4. package/dist/directives/federation.d.ts +6 -0
  5. package/dist/directives/federation.d.ts.map +1 -1
  6. package/dist/directives/index.d.ts +1 -2
  7. package/dist/directives/index.d.ts.map +1 -1
  8. package/dist/directives/permission.d.ts +20 -6
  9. package/dist/directives/permission.d.ts.map +1 -1
  10. package/dist/directives/removed-check.d.ts +15 -0
  11. package/dist/directives/removed-check.d.ts.map +1 -0
  12. package/dist/directives/scope.d.ts +3 -3
  13. package/dist/directives/scope.d.ts.map +1 -1
  14. package/dist/directives/sdl.d.ts +14 -4
  15. package/dist/directives/sdl.d.ts.map +1 -1
  16. package/dist/directives/validate.d.ts +8 -14
  17. package/dist/directives/validate.d.ts.map +1 -1
  18. package/dist/index.js +1055 -927
  19. package/dist/index.js.map +35 -31
  20. package/dist/integrations/hono.d.ts +2 -2
  21. package/dist/integrations/hono.d.ts.map +1 -1
  22. package/dist/plugins/apply-authenticated.d.ts +21 -0
  23. package/dist/plugins/apply-authenticated.d.ts.map +1 -0
  24. package/dist/plugins/apply-keto-checks.d.ts +10 -7
  25. package/dist/plugins/apply-keto-checks.d.ts.map +1 -1
  26. package/dist/plugins/auth.d.ts +17 -1
  27. package/dist/plugins/auth.d.ts.map +1 -1
  28. package/dist/plugins/authenticated.d.ts +14 -0
  29. package/dist/plugins/authenticated.d.ts.map +1 -0
  30. package/dist/plugins/extract-jwt.d.ts +19 -6
  31. package/dist/plugins/extract-jwt.d.ts.map +1 -1
  32. package/dist/plugins/field-guard.d.ts +30 -0
  33. package/dist/plugins/field-guard.d.ts.map +1 -0
  34. package/dist/plugins/gateway-trust.d.ts +47 -0
  35. package/dist/plugins/gateway-trust.d.ts.map +1 -0
  36. package/dist/plugins/index.d.ts +3 -0
  37. package/dist/plugins/index.d.ts.map +1 -1
  38. package/dist/plugins/keto-checks.d.ts +3 -2
  39. package/dist/plugins/keto-checks.d.ts.map +1 -1
  40. package/dist/plugins/keto-helpers.d.ts +1 -1
  41. package/dist/plugins/keto-helpers.d.ts.map +1 -1
  42. package/dist/plugins/ory-auth.d.ts +3 -3
  43. package/dist/shared.d.ts +1 -1
  44. package/dist/shared.d.ts.map +1 -1
  45. package/dist/utils/errors/denial.d.ts +20 -0
  46. package/dist/utils/errors/denial.d.ts.map +1 -0
  47. package/dist/utils/errors/format-error.d.ts +2 -1
  48. package/dist/utils/errors/format-error.d.ts.map +1 -1
  49. package/dist/utils/errors/index.d.ts +1 -0
  50. package/dist/utils/errors/index.d.ts.map +1 -1
  51. package/dist/utils/errors/mask-error.d.ts +2 -1
  52. package/dist/utils/errors/mask-error.d.ts.map +1 -1
  53. package/dist/utils/ws-context.d.ts +1 -1
  54. package/docs/README.md +8 -6
  55. package/docs/guide/authentication.md +187 -0
  56. package/docs/guide/errors.md +37 -5
  57. package/docs/guide/migrating-to-3.md +276 -0
  58. package/docs/guide/permissions.md +39 -50
  59. package/docs/roadmap.md +30 -29
  60. package/docs/troubleshooting.md +151 -48
  61. package/package.json +7 -11
  62. package/dist/directives/check.d.ts +0 -32
  63. package/dist/directives/check.d.ts.map +0 -1
  64. package/dist/directives/deprecation.d.ts +0 -7
  65. package/dist/directives/deprecation.d.ts.map +0 -1
  66. package/dist/directives/requirements.d.ts +0 -14
  67. package/dist/directives/requirements.d.ts.map +0 -1
  68. package/graphql/directives/check.graphqls +0 -90
@@ -1,10 +1,40 @@
1
1
  # Errors: `createMaskError` and `createFormatError`
2
2
 
3
- Services throw `CustomException` (from `@nxgt/shared-exceptions`), the
4
- directives throw it for a refusal, and `@nxgt/ory-sdk` throws `OryUnavailable`
5
- when Ory cannot answer. Neither is a `GraphQLError`, so a server has to be told
6
- how to answer them. These two functions do it — one for Yoga, one for Apollo
7
- Server.
3
+ The directives, `requireUser` and `can` throw a **denial**: a `GraphQLError`
4
+ that carries its own code and HTTP status, so any server answers it right.
5
+ Services throw `CustomException` (from `@nxgt/shared-exceptions`), and
6
+ `@nxgt/ory-sdk` throws `OryUnavailable` when Ory cannot answer. Neither of
7
+ those is a `GraphQLError`, so a server has to be told how to answer them —
8
+ and how to translate a denial's message with your resources. These two
9
+ functions do it — one for Yoga, one for Apollo Server.
10
+
11
+ ## Denials
12
+
13
+ ```ts
14
+ import { denial } from '@nxgt/shared-graphql';
15
+ import { ErrorCode } from '@nxgt/shared-exceptions';
16
+
17
+ throw denial(ErrorCode.NotFound, 'notes.errors.not-found');
18
+ ```
19
+
20
+ | Code | `extensions.http.status` | Thrown by |
21
+ | --- | --- | --- |
22
+ | `UNAUTHENTICATED` | 401 | `@permission`, `@authenticated`, `requireUser`, `can`, with no caller |
23
+ | `FORBIDDEN` | 403 | `@permission(onDeny: FORBIDDEN)`; `@authenticated(type:)` for a caller of another type |
24
+ | `NOT_FOUND` | 404 | `@permission`, by default |
25
+
26
+ The second argument is an i18n key — `errors.unauthenticated`,
27
+ `errors.insufficient-permissions` or `errors.not-found` when omitted. The
28
+ message is that key translated with `@nxgt/i18n`'s own resources, in the
29
+ request's language when one is known; a key those resources do not hold stays
30
+ as it is. `createMaskError(translate)` and `createFormatError(translate)`
31
+ translate it again with yours, and keep its code and status.
32
+
33
+ `denialMessageKey(error)` returns that key, for a mask of your own; the key is
34
+ never serialised to the client.
35
+
36
+ **Without `createMaskError`**, Yoga answers a denial with its status — it is
37
+ a `GraphQLError` thrown on purpose, which Yoga's default mask lets through.
8
38
 
9
39
  ## Yoga
10
40
 
@@ -27,6 +57,7 @@ new ApolloServer({ formatError: createFormatError(translate, isProduction) });
27
57
 
28
58
  | Thrown | `extensions.code` | `extensions.http.status` | message |
29
59
  | --- | --- | --- | --- |
60
+ | a `denial()` | its code | 401, 403 or 404 | its key, translated with your `translate` |
30
61
  | `CustomException.unauthenticated()` | `UNAUTHENTICATED` | 401 | translated key |
31
62
  | `CustomException.forbidden()` | `FORBIDDEN` | 403 | translated key |
32
63
  | `CustomException.notFound()` | `NOT_FOUND` | 404 | translated key |
@@ -55,6 +86,7 @@ decides it.
55
86
 
56
87
  | Thrown | `extensions.code` | message |
57
88
  | --- | --- | --- |
89
+ | a `denial()` | its code (`http.status` answers the transport) | its key, translated with your `translate` |
58
90
  | a `CustomException` or a Mongoose error | its `errorCode` (plus `debugMessage`) | translated key |
59
91
  | `OryUnavailable` | `SERVICE_UNAVAILABLE`, `http: { status: 503 }` in `extensions` | `ory: keto is unavailable` |
60
92
  | an Apollo validation or parse error | its own | translated `errors.<code>` |
@@ -0,0 +1,276 @@
1
+ # Migrating to 3.0
2
+
3
+ `@nxgt/shared-graphql` 3.0 is a major release. Most of it is the removals and
4
+ renames 2.x announced, plus one security fix that changes how the caller is
5
+ read. Each section below is one break: what changed, why, and the code before
6
+ and after.
7
+
8
+ | # | Break | You are affected if you… |
9
+ | --- | --- | --- |
10
+ | 1 | [`useAuth()` and `extractJwtPlugin` need a trusted gateway](#1-useauth-and-extractjwtplugin-need-a-trusted-gateway) | use either |
11
+ | 2 | [`@check` is removed](#2-check-is-removed) | write `@check`, or import `CHECK_DIRECTIVE_SDL` / `readChecks` / `readRequirements` |
12
+ | 3 | [`@authenticated` takes `type:`, and this package enforces it](#3-authenticated-takes-type) | declare `@authenticated` yourself, or load `SHARED_TYPE_DEFS` |
13
+ | 4 | [Five dependencies are gone](#4-five-dependencies-are-gone) | import one of them without declaring it |
14
+ | 5 | [`@nxgt/ory-sdk` is peered `>=0.1.0 <1`](#5-nxgtory-sdk-is-peered-010-1) | are on an `@nxgt/ory-sdk` 1.x (none is published) |
15
+ | 6 | [`graphql` is peered `^16.9.0 \|\| ^17.0.0`](#6-graphql-is-peered-1690--1700) | are on graphql 16.4 to 16.8 |
16
+ | 7 | [`sandboxExpolorer` is `sandboxExplorer`](#7-sandboxexpolorer-is-sandboxexplorer) | import it |
17
+ | 8 | [A denial is a `GraphQLError`](#8-a-denial-is-a-graphqlerror) | catch a denial as a `CustomException`, or read its `errorCode` |
18
+
19
+ ## 1. `useAuth()` and `extractJwtPlugin` need a trusted gateway
20
+
21
+ **Why.** Both copied the caller from the GraphQL request's `extensions` —
22
+ `user` and `token` for `useAuth()`, `payload` for `extractJwtPlugin`. That is
23
+ part of the request body, and any client that reaches the service writes it:
24
+
25
+ ```json
26
+ { "query": "{ me { id } }", "extensions": { "user": { "sub": "someone-else" } } }
27
+ ```
28
+
29
+ In 2.x that request was answered as `someone-else`. In 3.0 the caller is read
30
+ from `extensions` only for a request that proves it came from your gateway.
31
+
32
+ **Before**
33
+
34
+ ```ts
35
+ createYoga({ plugins: [useAuth()] });
36
+ new ApolloServer({ plugins: [extractJwtPlugin] });
37
+ ```
38
+
39
+ **After**
40
+
41
+ ```ts
42
+ import { extractJwtPlugin, gatewaySecret, useAuth } from '@nxgt/shared-graphql';
43
+
44
+ const trustedGateway = gatewaySecret({ secret: process.env.GATEWAY_SECRET! });
45
+
46
+ createYoga({ plugins: [useAuth({ trustedGateway })] });
47
+ new ApolloServer({ plugins: [extractJwtPlugin({ trustedGateway })] });
48
+ ```
49
+
50
+ Then make the gateway send the secret on every subgraph request, in the
51
+ `x-gateway-secret` header (or the one you name with `header`). A Hive Gateway,
52
+ for example:
53
+
54
+ ```ts
55
+ defineConfig({
56
+ propagateHeaders: {
57
+ fromClientToSubgraphs: () => ({ 'x-gateway-secret': process.env.GATEWAY_SECRET }),
58
+ },
59
+ });
60
+ ```
61
+
62
+ - A request without the header, or with a wrong one, keeps the context
63
+ without a caller: `user`, `token` and `jwt` are left as they were.
64
+ - `gatewaySecret` refuses, when it is built, a secret shorter than 16
65
+ characters — an unset variable stops the server instead of trusting an
66
+ empty header. It compares in constant time.
67
+ - `trustedGateway` can be any `(headers) => boolean | Promise<boolean>`, for a
68
+ proof other than a shared secret (an mTLS header your proxy sets, say).
69
+ - Called without a `trustedGateway`, both throw a `TypeError` when the server
70
+ starts:
71
+ ``useAuth(): name the gateway allowed to set the caller — …``.
72
+ - `extractJwtPlugin` is now a function, and no longer logs the payload.
73
+ - A `user` (or `payload`) without a non-empty string `sub` is ignored, even
74
+ from a trusted gateway.
75
+ - Over WebSocket, `useAuth()` reads no caller: a graphql-ws context holds no
76
+ fetch `Request` to carry the proof. Resolve the caller per connection with
77
+ `resolveWsUser`.
78
+
79
+ An API that authenticates its own callers — a Kratos session, a Hydra token —
80
+ does not need a gateway at all: use `useOryAuth(ory)`, which verifies the
81
+ credential server-side.
82
+
83
+ ## 2. `@check` is removed
84
+
85
+ **Why.** `@permission` replaced it in 2.1, and 3.0 drops the list-of-lists
86
+ form: one Keto question per directive, AND when repeated, and OR in the Keto
87
+ model.
88
+
89
+ **Before**
90
+
91
+ ```graphql
92
+ note(id: ID!): Note @check(permissions: [[{ namespace: "Note", permit: "view" }]])
93
+
94
+ updateNote(id: ID!): Note
95
+ @check(permissions: [[{ namespace: "Note", permit: "view" }]])
96
+ @check(permissions: [[{ namespace: "Note", permit: "edit" }]], onDeny: FORBIDDEN)
97
+
98
+ owner: Person @check(permissions: [[{ namespace: "Person", permit: "view", id: "parent.ownerId" }]])
99
+ ```
100
+
101
+ **After**
102
+
103
+ ```graphql
104
+ note(id: ID!): Note @permission(name: "view", type: "Note")
105
+
106
+ updateNote(id: ID!): Note
107
+ @permission(name: "view", type: "Note")
108
+ @permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
109
+
110
+ owner: Person @permission(name: "view", type: "Person", id: "parent.ownerId")
111
+ ```
112
+
113
+ `namespace` is `type`, `permit` is `name`; `id`, `onDeny` and `message` keep
114
+ their meaning. A term group with two terms (`[[A, B]]`, an AND) becomes two
115
+ `@permission`s. A requirement with two groups (`[[A], [B]]`, an OR) has no
116
+ `@permission` spelling: add a permit to the Keto model that unions the two
117
+ relations, and ask that one.
118
+
119
+ **In code:**
120
+
121
+ | 2.x | 3.0 |
122
+ | --- | --- |
123
+ | `CHECK_DIRECTIVE_SDL` | gone — `PERMISSION_DIRECTIVE_SDL` |
124
+ | `KETO_DIRECTIVES_SDL` | still exported, now `@permission` alone |
125
+ | `readChecks`, `readRequirements` | gone — `readPermissions` |
126
+ | `CheckArgs`, `CheckDenial` | `FieldPermission`, `PermissionDenial` |
127
+ | `graphql/directives/check.graphqls` | no longer shipped |
128
+
129
+ **What 2.x only warned about now stops the server.** An `args.<name>` the
130
+ field does not declare, and a guard on an interface field, were logged as
131
+ warnings on a `@check`. As `@permission`s they are `TypeError`s naming the
132
+ field, when the schema is built.
133
+
134
+ **A `@check` left behind does not boot.** Without its declaration, graphql
135
+ refuses it (`Unknown directive "@check"`). With a declaration of 2.x's shape
136
+ copied into your schema, `useKetoChecks` refuses it:
137
+
138
+ ```
139
+ @check on Query.note: @check was removed in @nxgt/shared-graphql 3.0 — write one @permission(name: "<permit>", type: "<namespace>") per term, and move an OR into the Keto model
140
+ ```
141
+
142
+ A `@check` directive of another shape (without `permissions`) is yours, and is
143
+ left alone.
144
+
145
+ ## 3. `@authenticated` takes `type:`
146
+
147
+ **Why.** A field meant for a machine client could not refuse a person's
148
+ session. `@authenticated(type: [String!])` names the kinds of caller a field
149
+ admits, and `useAuthenticated()` enforces it.
150
+
151
+ **Before** — `@authenticated` was declared, and enforced by an app's own
152
+ `useGenericAuth`:
153
+
154
+ ```ts
155
+ createYoga({ plugins: [useOryAuth(ory), useGenericAuth({ mode: 'protect-granular', resolveUserFn })] });
156
+ ```
157
+
158
+ **After**
159
+
160
+ ```graphql
161
+ me: User @authenticated
162
+ webhooks: [Webhook!]! @authenticated(type: ["token"])
163
+ ```
164
+
165
+ ```ts
166
+ import { useAuthenticated, useOryAuth } from '@nxgt/shared-graphql';
167
+
168
+ createYoga({ plugins: [useOryAuth(ory), useAuthenticated()] });
169
+ ```
170
+
171
+ - The type is Ory's `kind` — `session` or `token` — or `user.tokenType` when
172
+ no Ory principal is on the context. `useAuthenticated({ types: [...] })`
173
+ names other values; a `type` outside them is refused at build.
174
+ - An anonymous caller is `UNAUTHENTICATED` (401), a caller of another type
175
+ `FORBIDDEN` (403). An `@authenticated` scalar or enum guards every field
176
+ returning it, and a subscription is refused before its stream opens.
177
+ - `SHARED_TYPE_DEFS` now declares `@authenticated(type: [String!])`. If you
178
+ declare `@authenticated` yourself, add the argument or drop your
179
+ declaration; `AUTHENTICATED_DIRECTIVE_SDL` is the declaration as a string.
180
+ - **In a federation subgraph**, keep federation's own `@authenticated`, which
181
+ takes no argument: import it in `@link` as before. `useAuthenticated()`
182
+ reads that shape as "any caller", so the directive is enforced there too.
183
+ `FEDERATION_DIRECTIVES` keeps federation's shape for the same reason.
184
+
185
+ `useGenericAuth` keeps working beside it if you still need its other modes.
186
+ Declare `@envelop/generic-auth` yourself — see the next section.
187
+
188
+ ## 4. Five dependencies are gone
189
+
190
+ **Why.** Nothing in this package imported them:
191
+ `@graphql-hive/gateway`, `@envelop/generic-auth`,
192
+ `@envelop/extended-validation`, `@hono/zod-validator`, `zod`.
193
+
194
+ An app that imported one of them without declaring it got it through this
195
+ package, and now gets `Cannot find module`. Declare what you use:
196
+
197
+ ```diff
198
+ "dependencies": {
199
+ + "@envelop/generic-auth": "^11.1.1",
200
+ "@nxgt/shared-graphql": "^3.0.0"
201
+ }
202
+ ```
203
+
204
+ ## 5. `@nxgt/ory-sdk` is peered `>=0.1.0 <1`
205
+
206
+ **Why.** An open range admitted a breaking SDK major the day it was
207
+ published, untested. Every published version (0.1.x) is still admitted;
208
+ nothing changes for an install today. `@nxgt/security` and
209
+ `@nxgt/shared-hono` take the same ceiling.
210
+
211
+ ## 6. `graphql` is peered `^16.9.0 || ^17.0.0`
212
+
213
+ **Why.** The floor was 16.4.2, which nothing tested. The suite runs on a
214
+ graphql 16 above 16.9 and on graphql 17. On graphql 16.4 to 16.8, upgrade:
215
+
216
+ ```sh
217
+ bun add graphql@^16.9.0
218
+ ```
219
+
220
+ ## 7. `sandboxExpolorer` is `sandboxExplorer`
221
+
222
+ **Before**
223
+
224
+ ```ts
225
+ import { sandboxExpolorer } from '@nxgt/shared-graphql';
226
+ app.get('/sandbox', sandboxExpolorer({ port: 4000 }));
227
+ ```
228
+
229
+ **After**
230
+
231
+ ```ts
232
+ import { sandboxExplorer } from '@nxgt/shared-graphql';
233
+ app.get('/sandbox', sandboxExplorer({ port: 4000 }));
234
+ ```
235
+
236
+ No alias is kept. `createYogaHono` uses the new name for you.
237
+
238
+ ## 8. A denial is a `GraphQLError`
239
+
240
+ **Why.** `@permission`, `requireUser` and `can` threw a `CustomException`,
241
+ which Yoga masks as a 500 unless `createMaskError` is registered. They now
242
+ throw `denial(code, message?)`: a `GraphQLError` carrying
243
+ `extensions { code, http { status } }`, so any server answers 401, 403 or 404.
244
+ `@authenticated` throws the same.
245
+
246
+ What a client receives through `createMaskError` or `createFormatError` does
247
+ not change: the same code, status and translated message.
248
+
249
+ **Before** — code that caught a refusal:
250
+
251
+ ```ts
252
+ try {
253
+ requireUser(ctx);
254
+ } catch (error) {
255
+ if (error instanceof CustomException && error.errorCode === ErrorCode.Unauthenticated) { … }
256
+ }
257
+ ```
258
+
259
+ **After**
260
+
261
+ ```ts
262
+ import { GraphQLError } from 'graphql';
263
+
264
+ try {
265
+ requireUser(ctx);
266
+ } catch (error) {
267
+ if (error instanceof GraphQLError && error.extensions.code === 'UNAUTHENTICATED') { … }
268
+ }
269
+ ```
270
+
271
+ Without `createMaskError`, the message is the shared key translated with
272
+ `@nxgt/i18n`'s own resources (`Could not find the requested resource.`), in
273
+ the request's language when one is known. A custom `message:` key those
274
+ resources do not hold reaches the client as the key: register
275
+ `createMaskError(translate)` to translate it with yours. `denial` and
276
+ `denialMessageKey` are exported for a resolver or a mask of your own.
@@ -1,4 +1,4 @@
1
- # Permissions: `@permission`, `@check`, `useKetoChecks`
1
+ # Permissions: `@permission`, `useKetoChecks`
2
2
 
3
3
  An Ory-native API asks Keto one question per object: **may this caller
4
4
  `view` `Note:n1`**. This page is how a schema asks it, what answers it, and
@@ -33,21 +33,23 @@ const yoga = createYoga<{}, OryGraphQLContext>({
33
33
  });
34
34
  ```
35
35
 
36
- Both are needed: a guarded field reads `ory.subject`, which `useOryAuth` puts
37
- on the context — without it every guarded field answers `UNAUTHENTICATED`. `createMaskError` is what turns a denial into a 404/403 and an outage
38
- into a 503 — without it Yoga answers every one of them as an opaque 500.
36
+ `useOryAuth` is needed: a guarded field reads `ory.subject`, which it puts on
37
+ the context — without it every guarded field answers `UNAUTHENTICATED`.
38
+ `createMaskError` is optional for the denials, which carry their own status,
39
+ and is what translates their message with your `translate` and turns an
40
+ outage into a 503 — see [errors](./errors.md).
39
41
 
40
42
  A schema assembled in code, with no `SHARED_SCHEMA_PATH`, takes the
41
- declarations as a string:
43
+ declaration as a string:
42
44
 
43
45
  ```ts
44
- import { KETO_DIRECTIVES_SDL } from '@nxgt/shared-graphql';
46
+ import { PERMISSION_DIRECTIVE_SDL } from '@nxgt/shared-graphql';
45
47
 
46
- createSchema({ typeDefs: [KETO_DIRECTIVES_SDL, typeDefs], resolvers });
48
+ createSchema({ typeDefs: [PERMISSION_DIRECTIVE_SDL, typeDefs], resolvers });
47
49
  ```
48
50
 
49
- `PERMISSION_DIRECTIVE_SDL` and `CHECK_DIRECTIVE_SDL` are the two halves. Each
50
- equals its file in `graphql/directives/` — a spec parses and prints both.
51
+ It equals `graphql/directives/permission.graphqls` — a spec parses and prints
52
+ both. `KETO_DIRECTIVES_SDL` is the same string.
51
53
 
52
54
  ## `@permission`
53
55
 
@@ -82,10 +84,12 @@ updateNote(id: ID!, input: UpdateNoteInput!): Note!
82
84
 
83
85
  | Caller holds | Answer |
84
86
  | --- | --- |
85
- | nothing | `NOT_FOUND` — the same as an id that never existed, so ids cannot be probed |
86
- | `view` | `FORBIDDEN` — they can already see it, so "you may not change it" is honest |
87
+ | nothing | `NOT_FOUND` (404) — the same as an id that never existed, so ids cannot be probed |
88
+ | `view` | `FORBIDDEN` (403) — they can already see it, so "you may not change it" is honest |
87
89
  | `view` and `edit` | the resolver runs |
88
90
 
91
+ An anonymous caller is `UNAUTHENTICATED` (401) before Keto is asked anything.
92
+
89
93
  ### Where the id comes from
90
94
 
91
95
  ```graphql
@@ -128,71 +132,55 @@ check — and if they word the same 404 differently, the wording tells a caller
128
132
  which one refused: a generic message means "you may not", a domain one "it is
129
133
  gone". That is the distinction `NOT_FOUND` exists to hide.
130
134
 
131
- ## `@check` — deprecated
132
-
133
- The list-of-lists form. It keeps working, and is validated and evaluated
134
- exactly like `@permission` — in declaration order with it, on the same field:
135
-
136
- ```graphql
137
- note(id: ID!): Note
138
- @check(permissions: [[{ namespace: "Note", permit: "view" }]])
139
-
140
- either(id: ID!): Note
141
- @check(permissions: [
142
- [{ namespace: "Note", permit: "a" }, { namespace: "Note", permit: "b" }],
143
- [{ namespace: "Note", permit: "c" }]
144
- ])
145
- ```
146
-
147
- The outer list is OR, the inner list AND: `[[A, B], [C]]` reads
148
- "(A and B) or C". Moving a field to `@permission` one directive at a time
149
- keeps its ladder, since the order is read across both names:
135
+ ## `@check` — removed in 3.0
150
136
 
151
- ```graphql
152
- updateNote(id: ID!, input: UpdateNoteInput!): Note!
153
- @check(permissions: [[{ namespace: "Note", permit: "view" }]])
154
- @permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
155
- ```
156
-
157
- A field whose `@check` really needs an OR is the one to move into the Keto
158
- model first.
137
+ The list-of-lists form is gone. Rewrite each term as a `@permission`, and move
138
+ an OR into the Keto model — [the migration guide](./migrating-to-3.md#2-check-is-removed)
139
+ has the before and after. A `@check` left in a schema does not boot: see
140
+ [troubleshooting](../troubleshooting.md).
159
141
 
160
142
  ## Refused at build
161
143
 
162
144
  `applyKetoChecks` — which `useKetoChecks` runs on every schema change — reads
163
- every requirement when the schema is built, and throws a `TypeError` naming
145
+ every `@permission` when the schema is built, and throws a `TypeError` naming
164
146
  `Type.field` for:
165
147
 
166
148
  | Mistake | Message starts |
167
149
  | --- | --- |
168
150
  | a path naming no root | ``@permission on Query.note: `id` must be "args.<path>", …`` |
169
151
  | an argument the field does not declare | ``… `id` reads "args.id", but the field declares noteId`` |
170
- | an empty requirement or group (`@check`) | `… an empty group admits EVERYONE …` |
152
+ | an empty `name` or `type` | ``… every term needs `namespace`, `permit` and `id` …`` |
171
153
  | a namespace outside `namespaces` | `… unknown namespace "Noet" — known: Note, Folder` |
172
- | a guard on an interface field | `Node.id: @check / @permission on an interface field guards nothing …` |
173
-
174
- On a `@check` — which 2.x booted with them — the undeclared argument and the
175
- interface field are logged (`logger.warn` from `@nxgt/shared-logging`) instead
176
- of thrown, ending `— @check still boots with this; the next major refuses it,
177
- as @permission does`. Fix them now: the field they name answers 500 on every
178
- request, or is not guarded at all.
154
+ | a guard on an interface field | `Node.id: @permission on an interface field guards nothing …` |
155
+ | a `@check` of 2.x's shape | `@check on Query.note: @check was removed in @nxgt/shared-graphql 3.0 …` |
179
156
 
180
157
  `namespaces` is optional. Without it a namespace is taken on trust, and a
181
158
  misspelt one answers `false` for ever — Keto does not error on a namespace it
182
159
  does not know. Pass the namespaces of your OPL document to make that a boot
183
160
  failure.
184
161
 
162
+ `applyKetoChecks` leaves a field it already guarded alone, so a field is never
163
+ wrapped twice, and a field it has not — the other half of a merged schema —
164
+ is guarded when it runs again — as is a field whose resolver was replaced
165
+ since (`addResolversToSchema`, a merge with resolvers).
166
+
167
+ On a subscription, a `@permission` whose `id` reads `args.*` is asked before
168
+ `subscribe`, so a refused subscription opens no stream. One that reads
169
+ `parent.*` has no event to read yet: it is asked of each event instead, and a
170
+ refused event answers its denial while the stream stays open.
171
+
185
172
  ## From a resolver
186
173
 
187
174
  ```ts
188
- import { can, type OryGraphQLContext, requireUser } from '@nxgt/shared-graphql';
175
+ import { can, denial, type OryGraphQLContext, requireUser } from '@nxgt/shared-graphql';
176
+ import { ErrorCode } from '@nxgt/shared-exceptions';
189
177
 
190
178
  const resolvers = {
191
179
  Mutation: {
192
180
  archive: async (_: unknown, { id }: { id: string }, ctx: OryGraphQLContext) => {
193
181
  const user = requireUser(ctx);
194
182
  if (!(await can(ctx, { name: 'edit', type: 'Note', id }))) {
195
- throw CustomException.forbidden({ message: 'notes.errors.read-only' });
183
+ throw denial(ErrorCode.Forbidden, 'notes.errors.read-only');
196
184
  }
197
185
  return notes.archive(id, user.sub);
198
186
  },
@@ -200,7 +188,8 @@ const resolvers = {
200
188
  };
201
189
  ```
202
190
 
203
- - `requireUser(ctx)` returns `ctx.user`, or throws `UNAUTHENTICATED` (401).
191
+ - `requireUser(ctx)` returns `ctx.user`, or throws an `UNAUTHENTICATED`
192
+ denial (401).
204
193
  - `can(ctx, { name, type, id })` returns Keto's answer. It throws
205
194
  `UNAUTHENTICATED` with no caller, names the missing plugin without
206
195
  `useKetoChecks`, and lets `OryUnavailable` through on an outage — it never
package/docs/roadmap.md CHANGED
@@ -5,37 +5,14 @@ What `@nxgt/shared-graphql` does now, what is planned, and what it will not do.
5
5
  ## Now
6
6
 
7
7
  The GraphQL layer for Yoga and Apollo services: shared SDL, scalars and
8
- directives shipped in `graphql/`, `useOryAuth` and `useKetoChecks` for an
9
- Ory-native API, `createMaskError` / `createFormatError`, dataloaders,
10
- subscriptions over Redis, uploads and the Hono integration.
8
+ directives shipped in `graphql/`, `useOryAuth`, `useAuthenticated` and
9
+ `useKetoChecks` for an Ory-native API, `useAuth` behind a trusted gateway,
10
+ denials that carry their status, `createMaskError` / `createFormatError`,
11
+ dataloaders, subscriptions over Redis, uploads and the Hono integration.
11
12
 
12
- ## Next — a planned major
13
+ ## Next
13
14
 
14
- Each of these changes what an existing consumer gets, so they wait for a major
15
- release, together:
16
-
17
- - **`@check`'s mistakes refused at build**, as `@permission`'s are: an
18
- argument the field does not declare, and a guard on an interface field —
19
- today a warning.
20
- - **`@check` removed**, leaving `@permission`. A field whose `@check` holds an
21
- OR moves that OR into the Keto model first.
22
- - **`@authenticated(type: [String!])`** — refusing a caller of the wrong kind
23
- (a session where a client token is expected) at the directive. It changes
24
- the declaration that federation's own `@authenticated` shares, so it cannot
25
- be added in place.
26
- - **Dependencies this package does not import are dropped** —
27
- `@graphql-hive/gateway`, `@envelop/generic-auth`,
28
- `@envelop/extended-validation`, `@hono/zod-validator`, `zod`. An app that
29
- imports one of them without declaring it would stop installing it.
30
- - **`useAuth()` and `extractJwtPlugin` stop reading the caller from the request
31
- body's `extensions`** unless told which gateway may set them.
32
- - **`@nxgt/ory-sdk` peered with a ceiling** (`>=0.1.0 <1`), so a breaking SDK
33
- major is not admitted untested.
34
- - **The peer floors raised to what is tested** — `graphql` `^16.9.0 ||
35
- ^17.0.0`.
36
- - **The sandbox helper renamed** from `sandboxExpolorer` to `sandboxExplorer`.
37
- - **A denial thrown as a `GraphQLError`** with its `code` and HTTP status, so a
38
- server without `createMaskError` answers 404/403 rather than a masked 500.
15
+ Nothing planned that changes what a consumer gets. See Later.
39
16
 
40
17
  ## Later
41
18
 
@@ -52,6 +29,30 @@ release, together:
52
29
 
53
30
  ## Shipped
54
31
 
32
+ ### 3.0 — [migration guide](./guide/migrating-to-3.md)
33
+
34
+ - **The caller comes from a verified source only**: `useAuth()` and
35
+ `extractJwtPlugin()` read `extensions` only for a request
36
+ `trustedGateway` vouches for — `gatewaySecret({ secret })` by default — and
37
+ refuse to start without one.
38
+ - **`@check` removed**, leaving `@permission`; a `@check` left in a schema is
39
+ refused at build, and the mistakes 2.x only warned about on it are
40
+ `TypeError`s.
41
+ - **`@authenticated(type: [String!])`**, enforced by `useAuthenticated()`:
42
+ 401 for no caller, 403 for a caller of another type. Federation's
43
+ argument-less declaration is read as "any caller".
44
+ - **Denials are `GraphQLError`s** with `extensions { code, http { status } }`
45
+ — `denial(code, message?)` — so a server without `createMaskError` answers
46
+ 401/403/404 rather than a masked 500.
47
+ - **Five unused dependencies dropped**: `@graphql-hive/gateway`,
48
+ `@envelop/generic-auth`, `@envelop/extended-validation`,
49
+ `@hono/zod-validator`, `zod`.
50
+ - **Peers bounded**: `@nxgt/ory-sdk` `>=0.1.0 <1`, `graphql` `^16.9.0 ||
51
+ ^17.0.0`.
52
+ - **`sandboxExplorer`**, the sandbox helper's corrected name.
53
+
54
+ ### 2.1
55
+
55
56
  - **`@permission(name, type, id, onDeny, message)`**, the flat form, answered
56
57
  with `@check` in declaration order; `@check` deprecated in its favour.
57
58
  - **More refused at build**: an argument the field does not declare, a