@nxgt/shared-graphql 2.0.1 → 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 (79) hide show
  1. package/README.md +196 -81
  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 +5 -1
  7. package/dist/directives/index.d.ts.map +1 -1
  8. package/dist/directives/paths.d.ts +20 -0
  9. package/dist/directives/paths.d.ts.map +1 -0
  10. package/dist/directives/permission.d.ts +39 -0
  11. package/dist/directives/permission.d.ts.map +1 -0
  12. package/dist/directives/removed-check.d.ts +15 -0
  13. package/dist/directives/removed-check.d.ts.map +1 -0
  14. package/dist/directives/scope.d.ts +19 -0
  15. package/dist/directives/scope.d.ts.map +1 -0
  16. package/dist/directives/sdl.d.ts +26 -0
  17. package/dist/directives/sdl.d.ts.map +1 -0
  18. package/dist/directives/validate.d.ts +23 -0
  19. package/dist/directives/validate.d.ts.map +1 -0
  20. package/dist/index.js +1048 -564
  21. package/dist/index.js.map +37 -20
  22. package/dist/integrations/hono.d.ts +2 -2
  23. package/dist/integrations/hono.d.ts.map +1 -1
  24. package/dist/plugins/apply-authenticated.d.ts +21 -0
  25. package/dist/plugins/apply-authenticated.d.ts.map +1 -0
  26. package/dist/plugins/apply-keto-checks.d.ts +20 -0
  27. package/dist/plugins/apply-keto-checks.d.ts.map +1 -0
  28. package/dist/plugins/auth.d.ts +17 -1
  29. package/dist/plugins/auth.d.ts.map +1 -1
  30. package/dist/plugins/authenticated.d.ts +14 -0
  31. package/dist/plugins/authenticated.d.ts.map +1 -0
  32. package/dist/plugins/extract-jwt.d.ts +19 -6
  33. package/dist/plugins/extract-jwt.d.ts.map +1 -1
  34. package/dist/plugins/field-guard.d.ts +30 -0
  35. package/dist/plugins/field-guard.d.ts.map +1 -0
  36. package/dist/plugins/gateway-trust.d.ts +47 -0
  37. package/dist/plugins/gateway-trust.d.ts.map +1 -0
  38. package/dist/plugins/index.d.ts +6 -0
  39. package/dist/plugins/index.d.ts.map +1 -1
  40. package/dist/plugins/keto-checker.d.ts +24 -0
  41. package/dist/plugins/keto-checker.d.ts.map +1 -0
  42. package/dist/plugins/keto-checks.d.ts +11 -34
  43. package/dist/plugins/keto-checks.d.ts.map +1 -1
  44. package/dist/plugins/keto-helpers.d.ts +38 -0
  45. package/dist/plugins/keto-helpers.d.ts.map +1 -0
  46. package/dist/plugins/ory-auth.d.ts +5 -5
  47. package/dist/plugins/ory-auth.d.ts.map +1 -1
  48. package/dist/scalars/custom/utils.d.ts.map +1 -1
  49. package/dist/shared.d.ts +1 -1
  50. package/dist/shared.d.ts.map +1 -1
  51. package/dist/utils/errors/create-error.d.ts +3 -0
  52. package/dist/utils/errors/create-error.d.ts.map +1 -0
  53. package/dist/utils/errors/denial.d.ts +20 -0
  54. package/dist/utils/errors/denial.d.ts.map +1 -0
  55. package/dist/utils/errors/format-error.d.ts +12 -0
  56. package/dist/utils/errors/format-error.d.ts.map +1 -0
  57. package/dist/utils/errors/index.d.ts +6 -0
  58. package/dist/utils/errors/index.d.ts.map +1 -0
  59. package/dist/utils/errors/mask-error.d.ts +25 -0
  60. package/dist/utils/errors/mask-error.d.ts.map +1 -0
  61. package/dist/utils/errors/ory-unavailable.d.ts +19 -0
  62. package/dist/utils/errors/ory-unavailable.d.ts.map +1 -0
  63. package/dist/utils/index.d.ts +1 -1
  64. package/dist/utils/index.d.ts.map +1 -1
  65. package/dist/utils/ws-context.d.ts +1 -1
  66. package/docs/README.md +13 -0
  67. package/docs/guide/authentication.md +187 -0
  68. package/docs/guide/errors.md +136 -0
  69. package/docs/guide/migrating-to-3.md +276 -0
  70. package/docs/guide/permissions.md +220 -0
  71. package/docs/roadmap.md +71 -0
  72. package/docs/troubleshooting.md +273 -0
  73. package/graphql/directives/permission.graphqls +73 -0
  74. package/package.json +11 -13
  75. package/dist/directives/check.d.ts +0 -40
  76. package/dist/directives/check.d.ts.map +0 -1
  77. package/dist/utils/errors.utils.d.ts +0 -23
  78. package/dist/utils/errors.utils.d.ts.map +0 -1
  79. package/graphql/directives/check.graphqls +0 -86
@@ -0,0 +1,187 @@
1
+ # Authentication: who is calling, and `@authenticated`
2
+
3
+ A resolver reads the caller from `context.user`. This page is where that value
4
+ comes from, which sources are trusted, and how `@authenticated` refuses a
5
+ missing caller or one of the wrong kind.
6
+
7
+ ## The rule: a verified source, never the body alone
8
+
9
+ The GraphQL request's `extensions` travel in the request body, which any
10
+ client writes. So the caller comes from one of two places:
11
+
12
+ | Source | Plugin | Verified by |
13
+ | --- | --- | --- |
14
+ | a Kratos session or a Hydra access token | `useOryAuth(ory)` | Ory, server-side, on every request |
15
+ | what a gateway resolved and put in `extensions` | `useAuth()` / `extractJwtPlugin()` | the gateway's proof — `trustedGateway` — on every request |
16
+
17
+ Anything else leaves the context without a caller.
18
+
19
+ ## `useOryAuth(ory)` — an API that authenticates its own callers
20
+
21
+ ```ts
22
+ import { createOry } from '@nxgt/ory-sdk';
23
+ import { useAuthenticated, useOryAuth } from '@nxgt/shared-graphql';
24
+
25
+ const ory = createOry({ /* … */ });
26
+
27
+ createYoga({ plugins: [useOryAuth(ory), useAuthenticated()] });
28
+ ```
29
+
30
+ It reads `Authorization: Bearer`, then `X-Session-Token`, then the Kratos
31
+ cookie, and puts `user`, `claims`, `token` and `ory` on the context. An Ory
32
+ outage throws a 503 `SERVICE_UNAVAILABLE`, never an anonymous caller.
33
+
34
+ ## `useAuth()` and `extractJwtPlugin()` — behind a gateway
35
+
36
+ A gateway that authenticated the caller can forward it to subgraphs in the
37
+ request's `extensions`: `user` and `token` for `useAuth()` (Yoga),
38
+ `payload` for `extractJwtPlugin()` (Apollo Server). Both read it **only** when
39
+ `trustedGateway` holds for the request:
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
+ ### `gatewaySecret({ secret, header? })`
51
+
52
+ Holds for a request whose `header` — `x-gateway-secret` by default — carries
53
+ `secret`, compared in constant time. The gateway adds it to every subgraph
54
+ request; a client that reaches the service directly does not know it, and its
55
+ `extensions` are ignored.
56
+
57
+ | Option | Default | Meaning |
58
+ | --- | --- | --- |
59
+ | `secret` | — | shared with the gateway only; **at least 16 characters**, or `gatewaySecret` throws when it is built |
60
+ | `header` | `x-gateway-secret` | the header the gateway sends it in |
61
+
62
+ The length check is there for the day `GATEWAY_SECRET` is unset: the server
63
+ stops at boot instead of trusting an empty header.
64
+
65
+ ### Another proof
66
+
67
+ `trustedGateway` is any function of the request's headers:
68
+
69
+ ```ts
70
+ type GatewayTrust = (headers: { get(name: string): string | null | undefined }) => boolean | Promise<boolean>;
71
+
72
+ // Your proxy terminates mTLS and sets this header only for the gateway's certificate.
73
+ useAuth({ trustedGateway: (headers) => headers.get('x-client-cert-subject') === 'CN=gateway' });
74
+ ```
75
+
76
+ It receives a fetch `Headers` under Yoga and Apollo's `HeaderMap` under
77
+ Apollo Server; both answer `get`.
78
+
79
+ ### What is read
80
+
81
+ - `useAuth()`: `extensions.user` when it is an object with a non-empty string
82
+ `sub`, and `extensions.token` when it is a string. Otherwise nothing.
83
+ - `extractJwtPlugin()`: `extensions.payload`, under the same `sub` rule, on
84
+ `context.jwt.payload`.
85
+ - A request that fails the proof leaves `user`, `token` and `jwt` as they
86
+ were — another plugin's caller is not erased.
87
+ - Over WebSocket, `useAuth()` reads no caller: the proof needs a fetch
88
+ `Request`, which a graphql-ws context does not hold. Resolve the caller per
89
+ connection with `resolveWsUser` instead.
90
+
91
+ Without a `trustedGateway`, both throw a `TypeError` when called — see
92
+ [troubleshooting](../troubleshooting.md).
93
+
94
+ ## `@authenticated(type: [String!])`
95
+
96
+ ```graphql
97
+ directive @authenticated(type: [String!]) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
98
+ ```
99
+
100
+ Declared in `SHARED_TYPE_DEFS` and in `AUTHENTICATED_DIRECTIVE_SDL`; enforced
101
+ by `useAuthenticated()`, or `applyAuthenticated(schema)` on a built schema.
102
+
103
+ ```graphql
104
+ type Query {
105
+ me: User @authenticated # any caller
106
+ webhooks: [Webhook!]! @authenticated(type: ["token"]) # a machine client, not a session
107
+ }
108
+
109
+ interface Node @authenticated { id: ID! }
110
+ type Staff implements Node @authenticated(type: ["session"]) { id: ID!, name: String }
111
+ ```
112
+
113
+ | Caller | Answer |
114
+ | --- | --- |
115
+ | none (`context.user` unset) | `UNAUTHENTICATED`, HTTP 401 |
116
+ | of a type no applicable `type:` names | `FORBIDDEN`, HTTP 403 |
117
+ | otherwise | the resolver runs |
118
+
119
+ - **The caller's type** is `context.ory.kind` — `session` for a Kratos
120
+ session, `token` for an access token — or `context.user.tokenType` when no
121
+ Ory principal is on the context.
122
+ - **Every directive that applies is AND-ed**: the field's, its type's, its
123
+ type's interfaces', and those interfaces' same field. `Staff.name` above
124
+ needs a caller (`Node`) who is a session (`Staff`).
125
+ - **A scalar's or an enum's directive guards every field returning it** —
126
+ `scalar Secret @authenticated` refuses `Query.secret: Secret` to an
127
+ anonymous caller, as federation's router reads it.
128
+ - **A subscription is refused before its stream opens**: the check runs
129
+ before the field's `subscribe`, and again before each event is resolved.
130
+ - **A type's directive guards its fields, not the field returning it.**
131
+ `Query.staff` runs for an anonymous caller; the error lands on
132
+ `staff.name`. Put `@authenticated` on the field too when the lookup must not
133
+ run.
134
+
135
+ ### Other caller types
136
+
137
+ ```ts
138
+ import { CALLER_TYPES, useAuthenticated } from '@nxgt/shared-graphql';
139
+
140
+ useAuthenticated({ types: [...CALLER_TYPES, 'service'] });
141
+ ```
142
+
143
+ `types` lists every value a `type:` may name; `CALLER_TYPES` —
144
+ `['session', 'token']` — is the default. Behind a gateway, it is the set
145
+ of `tokenType`s your gateway writes.
146
+
147
+ ### Refused at build
148
+
149
+ `applyAuthenticated` throws a `TypeError` for a directive no request could
150
+ pass, naming where it sits — `Query.me` on a field, `Staff` on a type,
151
+ `Secret` on a scalar:
152
+
153
+ | Mistake | Message starts |
154
+ | --- | --- |
155
+ | `type: []` | ``@authenticated on Query.me: `type: []` admits no caller`` |
156
+ | a type outside `types` | `@authenticated on Query.me: unknown type "staff" — known: session, token` |
157
+ | restrictions with no type in common | ``@authenticated on Note.body: the field's, its type's and its interfaces' `type`s have none in common`` |
158
+
159
+ ### Federation's `@authenticated`
160
+
161
+ Federation defines its own `@authenticated`, with **no argument**, and a
162
+ subgraph imports it:
163
+
164
+ ```graphql
165
+ extend schema @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@authenticated"])
166
+ ```
167
+
168
+ `useAuthenticated` accepts that shape: an `@authenticated` whose declaration
169
+ has no `type` means "any caller", and is enforced as such. So in a subgraph,
170
+ keep federation's declaration — `FEDERATION_DIRECTIVES` carries it unchanged
171
+ — and do not load `SHARED_TYPE_DEFS` beside it. `type:` is for a schema that
172
+ declares `@authenticated` through `SHARED_TYPE_DEFS` or
173
+ `AUTHENTICATED_DIRECTIVE_SDL`.
174
+
175
+ ### Beside `useKetoChecks`
176
+
177
+ Both plugins replace the schema. Each marks the fields it guarded and leaves a
178
+ marked field alone, so the two settle on one schema in either order — and a
179
+ schema merged from a guarded half and an unguarded one gets the second half
180
+ guarded when it is transformed again, as is a field whose resolver was
181
+ replaced since. On a field carrying both,
182
+ `@authenticated` is checked before `@permission` whatever the plugin order,
183
+ so a caller of the wrong type is `FORBIDDEN` before Keto is asked:
184
+
185
+ ```ts
186
+ plugins: [useOryAuth(ory), useAuthenticated(), useKetoChecks(ory)]
187
+ ```
@@ -0,0 +1,136 @@
1
+ # Errors: `createMaskError` and `createFormatError`
2
+
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.
38
+
39
+ ## Yoga
40
+
41
+ ```ts
42
+ import { createMaskError } from '@nxgt/shared-graphql';
43
+ import { translate } from './i18n';
44
+
45
+ createYoga({ maskedErrors: { maskError: createMaskError(translate) } });
46
+ ```
47
+
48
+ ## Apollo Server
49
+
50
+ ```ts
51
+ import { createFormatError } from '@nxgt/shared-graphql';
52
+
53
+ new ApolloServer({ formatError: createFormatError(translate, isProduction) });
54
+ ```
55
+
56
+ ## What a client receives — Yoga, through `createMaskError`
57
+
58
+ | Thrown | `extensions.code` | `extensions.http.status` | message |
59
+ | --- | --- | --- | --- |
60
+ | a `denial()` | its code | 401, 403 or 404 | its key, translated with your `translate` |
61
+ | `CustomException.unauthenticated()` | `UNAUTHENTICATED` | 401 | translated key |
62
+ | `CustomException.forbidden()` | `FORBIDDEN` | 403 | translated key |
63
+ | `CustomException.notFound()` | `NOT_FOUND` | 404 | translated key |
64
+ | any other `CustomException` | its `errorCode` | its status | translated key |
65
+ | a Mongoose error | through `castError` | its status | translated key |
66
+ | `OryUnavailable` | `SERVICE_UNAVAILABLE` | 503 | `ory: keto is unavailable` |
67
+ | a `GraphQLError` thrown on purpose | its own | its own | its own |
68
+ | a plain `Error` a resolver threw | `INTERNAL_SERVER_ERROR` | — | the mask message |
69
+
70
+ ```json
71
+ {
72
+ "errors": [
73
+ {
74
+ "message": "Could not find the requested resource.",
75
+ "path": ["note"],
76
+ "extensions": { "code": "NOT_FOUND", "http": { "status": 404 } }
77
+ }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ The HTTP status applies when it is the only error of the response, as Yoga
83
+ decides it.
84
+
85
+ ## What a client receives — Apollo, through `createFormatError`
86
+
87
+ | Thrown | `extensions.code` | message |
88
+ | --- | --- | --- |
89
+ | a `denial()` | its code (`http.status` answers the transport) | its key, translated with your `translate` |
90
+ | a `CustomException` or a Mongoose error | its `errorCode` (plus `debugMessage`) | translated key |
91
+ | `OryUnavailable` | `SERVICE_UNAVAILABLE`, `http: { status: 503 }` in `extensions` | `ory: keto is unavailable` |
92
+ | an Apollo validation or parse error | its own | translated `errors.<code>` |
93
+ | anything else | as Apollo formats it | as Apollo formats it — not masked here |
94
+
95
+ `formatError` shapes the error body; it does not set the response's status,
96
+ so `http.status` is information for the client, not the transport's answer.
97
+ The stack trace is removed when the second argument, `production`, is true.
98
+
99
+ ## An outage is a 503, never a denial
100
+
101
+ Kratos, Hydra or Keto failing to answer is not "anonymous" and not "denied".
102
+ Answering it as either would lock every caller out in silence — or, for a
103
+ check that defaulted open, let everyone in. So `OryUnavailable` is never caught
104
+ as a refusal anywhere in this package, and both functions answer it
105
+ `SERVICE_UNAVAILABLE` — with HTTP 503 under Yoga — which a client can retry.
106
+
107
+ It is recognised by class, and also by its name and shape: an install that
108
+ ends up with two copies of `@nxgt/ory-sdk` throws an `OryUnavailable` whose
109
+ class is not the one this package imported, and that must still be a 503
110
+ rather than a masked 500. `isOryUnavailable(error)` is the test, and
111
+ `serviceUnavailableError(error)` builds the 503 `GraphQLError` both use —
112
+ exported for a server that writes its own `maskError`:
113
+
114
+ ```ts
115
+ import { isOryUnavailable, serviceUnavailableError } from '@nxgt/shared-graphql';
116
+ import { GraphQLError } from 'graphql';
117
+ import type { MaskError } from 'graphql-yoga';
118
+
119
+ const maskError: MaskError = (error, message) => {
120
+ const original = error instanceof GraphQLError ? error.originalError : error;
121
+ if (isOryUnavailable(original)) return serviceUnavailableError(original);
122
+ // …
123
+ };
124
+ ```
125
+
126
+ `oryUnavailableError`, which `useOryAuth` throws when it cannot resolve the
127
+ caller, is the same function under its older name.
128
+
129
+ ## Internal messages stay internal
130
+
131
+ graphql-js wraps every error a resolver throws in a `GraphQLError`. A wrapper
132
+ around a plain `Error` — a driver's `connect ECONNREFUSED 10.0.0.5:27017` — is
133
+ replaced by the mask message, as Yoga's default mask does; its `path` is kept.
134
+ In development (`isDev`) the original is in `extensions.debugMessage`.
135
+
136
+ A `GraphQLError` you throw yourself, and every validation error, passes as is.
@@ -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.