@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,220 @@
1
+ # Permissions: `@permission`, `useKetoChecks`
2
+
3
+ An Ory-native API asks Keto one question per object: **may this caller
4
+ `view` `Note:n1`**. This page is how a schema asks it, what answers it, and
5
+ what happens when the answer cannot be had.
6
+
7
+ ## Wiring
8
+
9
+ ```ts
10
+ import { createOry } from '@nxgt/ory-sdk';
11
+ import {
12
+ createMaskError,
13
+ type OryGraphQLContext,
14
+ SHARED_SCHEMA_PATH,
15
+ loadTypeDefs,
16
+ useKetoChecks,
17
+ useOryAuth,
18
+ } from '@nxgt/shared-graphql';
19
+ import { createSchema, createYoga } from 'graphql-yoga';
20
+
21
+ const ory = createOry({ /* … */ });
22
+
23
+ const yoga = createYoga<{}, OryGraphQLContext>({
24
+ schema: createSchema({
25
+ typeDefs: loadTypeDefs(SHARED_SCHEMA_PATH, './src/**/*.graphqls'),
26
+ resolvers,
27
+ }),
28
+ plugins: [
29
+ useOryAuth(ory), // puts `ory`, `user`, `claims`, `token` on the context
30
+ useKetoChecks(ory, { namespaces: ['Note', 'Folder'] }),
31
+ ],
32
+ maskedErrors: { maskError: createMaskError(translate) },
33
+ });
34
+ ```
35
+
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).
41
+
42
+ A schema assembled in code, with no `SHARED_SCHEMA_PATH`, takes the
43
+ declaration as a string:
44
+
45
+ ```ts
46
+ import { PERMISSION_DIRECTIVE_SDL } from '@nxgt/shared-graphql';
47
+
48
+ createSchema({ typeDefs: [PERMISSION_DIRECTIVE_SDL, typeDefs], resolvers });
49
+ ```
50
+
51
+ It equals `graphql/directives/permission.graphqls` — a spec parses and prints
52
+ both. `KETO_DIRECTIVES_SDL` is the same string.
53
+
54
+ ## `@permission`
55
+
56
+ ```graphql
57
+ directive @permission(
58
+ name: String!
59
+ type: String!
60
+ id: String
61
+ onDeny: PermissionDenial! = NOT_FOUND
62
+ message: String
63
+ ) repeatable on FIELD_DEFINITION
64
+ ```
65
+
66
+ | Argument | Meaning |
67
+ | --- | --- |
68
+ | `name` | the permit asked of Keto — `view`, `edit`; a plain relation works too |
69
+ | `type` | the Keto namespace — `Note` |
70
+ | `id` | where the object id is read: `args.<path>` or `parent.<path>`; `args.id` when omitted |
71
+ | `onDeny` | `NOT_FOUND` (default) or `FORBIDDEN` |
72
+ | `message` | the i18n key the denial carries |
73
+
74
+ ### The 404-then-403 ladder
75
+
76
+ Repeated `@permission`s are AND, evaluated **in declaration order**, and each
77
+ answers its own `onDeny`:
78
+
79
+ ```graphql
80
+ updateNote(id: ID!, input: UpdateNoteInput!): Note!
81
+ @permission(name: "view", type: "Note")
82
+ @permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
83
+ ```
84
+
85
+ | Caller holds | Answer |
86
+ | --- | --- |
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 |
89
+ | `view` and `edit` | the resolver runs |
90
+
91
+ An anonymous caller is `UNAUTHENTICATED` (401) before Keto is asked anything.
92
+
93
+ ### Where the id comes from
94
+
95
+ ```graphql
96
+ note(id: ID!): Note @permission(name: "view", type: "Note")
97
+ noteByKey(key: ID!): Note @permission(name: "view", type: "Note", id: "args.key")
98
+ deleteNotes(ids: [ID!]!): Boolean @permission(name: "delete", type: "Note", id: "args.ids")
99
+
100
+ type Note {
101
+ ownerId: ID!
102
+ owner: Person @permission(name: "view", type: "Person", id: "parent.ownerId")
103
+ }
104
+ ```
105
+
106
+ A **list** requires the permit on every element, and stops at the first
107
+ refusal. `source.<path>` is accepted as the same root as `parent.<path>`.
108
+
109
+ A path that resolves no id at request time — a nullable argument nobody
110
+ passed, a parent field that was not loaded — is a wiring error (500), never an
111
+ allow.
112
+
113
+ ### Or, in the model
114
+
115
+ There is no OR between `@permission`s. When a field is open to owners *or*
116
+ editors, say so in the Keto model — a permit that unions the two relations —
117
+ and ask that permit.
118
+
119
+ ### Wording a refusal
120
+
121
+ `message` defaults to the shared `errors.not-found` /
122
+ `errors.insufficient-permissions`. Set it whenever the service behind the field
123
+ refuses the same object with a domain message:
124
+
125
+ ```graphql
126
+ note(id: ID!): Note
127
+ @permission(name: "view", type: "Note", message: "notes.errors.not-found")
128
+ ```
129
+
130
+ Two layers guard these fields — the directive and the service's own access
131
+ check — and if they word the same 404 differently, the wording tells a caller
132
+ which one refused: a generic message means "you may not", a domain one "it is
133
+ gone". That is the distinction `NOT_FOUND` exists to hide.
134
+
135
+ ## `@check` — removed in 3.0
136
+
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).
141
+
142
+ ## Refused at build
143
+
144
+ `applyKetoChecks` — which `useKetoChecks` runs on every schema change — reads
145
+ every `@permission` when the schema is built, and throws a `TypeError` naming
146
+ `Type.field` for:
147
+
148
+ | Mistake | Message starts |
149
+ | --- | --- |
150
+ | a path naming no root | ``@permission on Query.note: `id` must be "args.<path>", …`` |
151
+ | an argument the field does not declare | ``… `id` reads "args.id", but the field declares noteId`` |
152
+ | an empty `name` or `type` | ``… every term needs `namespace`, `permit` and `id` …`` |
153
+ | a namespace outside `namespaces` | `… unknown namespace "Noet" — known: Note, Folder` |
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 …` |
156
+
157
+ `namespaces` is optional. Without it a namespace is taken on trust, and a
158
+ misspelt one answers `false` for ever — Keto does not error on a namespace it
159
+ does not know. Pass the namespaces of your OPL document to make that a boot
160
+ failure.
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
+
172
+ ## From a resolver
173
+
174
+ ```ts
175
+ import { can, denial, type OryGraphQLContext, requireUser } from '@nxgt/shared-graphql';
176
+ import { ErrorCode } from '@nxgt/shared-exceptions';
177
+
178
+ const resolvers = {
179
+ Mutation: {
180
+ archive: async (_: unknown, { id }: { id: string }, ctx: OryGraphQLContext) => {
181
+ const user = requireUser(ctx);
182
+ if (!(await can(ctx, { name: 'edit', type: 'Note', id }))) {
183
+ throw denial(ErrorCode.Forbidden, 'notes.errors.read-only');
184
+ }
185
+ return notes.archive(id, user.sub);
186
+ },
187
+ },
188
+ };
189
+ ```
190
+
191
+ - `requireUser(ctx)` returns `ctx.user`, or throws an `UNAUTHENTICATED`
192
+ denial (401).
193
+ - `can(ctx, { name, type, id })` returns Keto's answer. It throws
194
+ `UNAUTHENTICATED` with no caller, names the missing plugin without
195
+ `useKetoChecks`, and lets `OryUnavailable` through on an outage — it never
196
+ answers `false` for a question it could not ask.
197
+
198
+ ## The per-request memo
199
+
200
+ `useKetoChecks` puts one `ketoChecks` function on each request's context. It
201
+ **batches** the distinct questions of one tick into a single
202
+ `POST /relation-tuples/batch/check` and **memoises** answers for the request.
203
+ A field guarded by `@permission(view)` and a service that asks the same
204
+ question through `can` pay for one round trip between them.
205
+
206
+ A batch that failed is not remembered: every question in it rejects with the
207
+ outage, and the next ask goes back to Keto.
208
+
209
+ ## An outage is not a no
210
+
211
+ `@nxgt/ory-sdk` throws `OryUnavailable` when Keto cannot answer. Nothing in
212
+ this package catches it as a denial: the directive, `can` and the memo all
213
+ let it through, and `createMaskError` / `createFormatError` answer it
214
+ `SERVICE_UNAVAILABLE` with HTTP 503. See [errors](./errors.md).
215
+
216
+ ## Not a check: lists
217
+
218
+ "Which notes may I see" is a Keto query folded into the database filter before
219
+ the read, not a directive. A directive on a list field would have to fetch
220
+ everything and filter after, which makes `totalCount` and the cursors lie.
@@ -0,0 +1,71 @@
1
+ # Roadmap
2
+
3
+ What `@nxgt/shared-graphql` does now, what is planned, and what it will not do.
4
+
5
+ ## Now
6
+
7
+ The GraphQL layer for Yoga and Apollo services: shared SDL, scalars and
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.
12
+
13
+ ## Next
14
+
15
+ Nothing planned that changes what a consumer gets. See Later.
16
+
17
+ ## Later
18
+
19
+ - **Batching a list argument's ids** into one Keto batch rather than one
20
+ question per id in order.
21
+
22
+ ## Not planned
23
+
24
+ - **A directive on a list field** that filters after the read. "Which objects
25
+ may I see" is a Keto query folded into the database filter.
26
+ - **OR between directives.** It belongs in the Keto model.
27
+ - **Turning an Ory outage into a denial or an anonymous caller**, under any
28
+ option. It is a 503.
29
+
30
+ ## Shipped
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
+
56
+ - **`@permission(name, type, id, onDeny, message)`**, the flat form, answered
57
+ with `@check` in declaration order; `@check` deprecated in its favour.
58
+ - **More refused at build**: an argument the field does not declare, a
59
+ namespace outside the model (`namespaces`), a guard on an interface field —
60
+ each a `TypeError` naming the field for `@permission`, and a warning for
61
+ `@check`, which booted with the first and last before.
62
+ - **`requireUser`, `can` and `OryGraphQLContext`** for resolvers, through the
63
+ same per-request memo.
64
+ - **The directive SDL as strings** — `PERMISSION_DIRECTIVE_SDL`,
65
+ `CHECK_DIRECTIVE_SDL`, `KETO_DIRECTIVES_SDL` — held equal to the files.
66
+ - **graphql 17**: `graphql` is a peer, `^16.4.2 || ^17.0.0`, and the suite
67
+ runs on both.
68
+ - **An outage under Apollo is a 503 too**, and one from a second copy of
69
+ `@nxgt/ory-sdk` is still recognised.
70
+ - **Internal messages masked**: a plain `Error` a resolver threw no longer
71
+ reaches the client through `createMaskError`.
@@ -0,0 +1,273 @@
1
+ # Troubleshooting `@nxgt/shared-graphql`
2
+
3
+ Each entry is headed by the message you see; the parts in `<angle brackets>`
4
+ vary. Messages thrown when the server starts or the schema is built are
5
+ `TypeError`s, and stop the server from booting; the schema ones name
6
+ `Type.field` after the directive that carries the mistake. The last section
7
+ holds the traps that throw nothing.
8
+
9
+ Upgrading from 2.x, most of these come from [the
10
+ migration](./guide/migrating-to-3.md).
11
+
12
+ ## When the server starts
13
+
14
+ ### ``useAuth(): name the gateway allowed to set the caller — useAuth({ trustedGateway: gatewaySecret({ secret }) }). …``
15
+
16
+ Also `extractJwtPlugin(): name the gateway allowed to set the caller — …`.
17
+
18
+ Since 3.0 both need to know which gateway may put the caller in the request's
19
+ `extensions`, which a client can otherwise write. Pass one:
20
+
21
+ ```ts
22
+ const trustedGateway = gatewaySecret({ secret: process.env.GATEWAY_SECRET! });
23
+ createYoga({ plugins: [useAuth({ trustedGateway })] });
24
+ new ApolloServer({ plugins: [extractJwtPlugin({ trustedGateway })] });
25
+ ```
26
+
27
+ `extractJwtPlugin` is a function now: `plugins: [extractJwtPlugin]` passes
28
+ the function itself, not a plugin. An API that authenticates its own callers
29
+ uses `useOryAuth(ory)` and needs neither.
30
+
31
+ ### ``gatewaySecret(): `secret` must be a string of at least 16 characters — is its environment variable set?``
32
+
33
+ The secret is missing or shorter than 16 characters — most often an
34
+ environment variable that is not set where the server runs. Set it, to the
35
+ same value the gateway sends.
36
+
37
+ ### `Cannot find module '@envelop/generic-auth'`
38
+
39
+ Or `@graphql-hive/gateway`, `@envelop/extended-validation`,
40
+ `@hono/zod-validator`, `zod`. 3.0 dropped them from its dependencies — nothing
41
+ here imported them — and your app was using the copy this package brought.
42
+ Declare it yourself:
43
+
44
+ ```sh
45
+ bun add @envelop/generic-auth
46
+ ```
47
+
48
+ ### ``Module '"@nxgt/shared-graphql"' has no exported member 'sandboxExpolorer'. Did you mean 'sandboxExplorer'?``
49
+
50
+ The typo was corrected in 3.0, with no alias. Import `sandboxExplorer`.
51
+
52
+ ## When the schema is built
53
+
54
+ ### ``@check on <Type.field>: @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``
55
+
56
+ The schema declares 2.x's `@check` itself and a field still uses it. This
57
+ package no longer answers it, so the field would boot unguarded; it is refused
58
+ instead. Rewrite it:
59
+
60
+ ```graphql
61
+ # before
62
+ note(id: ID!): Note @check(permissions: [[{ namespace: "Note", permit: "view" }]])
63
+ # after
64
+ note(id: ID!): Note @permission(name: "view", type: "Note")
65
+ ```
66
+
67
+ Then drop your `@check` declaration. [The migration guide](./guide/migrating-to-3.md#2-check-is-removed)
68
+ covers an AND and an OR.
69
+
70
+ ### `Unknown directive "@check".`
71
+
72
+ The same, in a schema that loads this package's `graphql/` and nothing else:
73
+ 3.0 no longer ships `@check`'s declaration. Rewrite the field as above.
74
+
75
+ ### ``@permission on <Type.field>: `id` must be "args.<path>", "parent.<path>" or "source.<path>", got "<id>"``
76
+
77
+ The `id` argument names no root. Prefix it:
78
+
79
+ ```graphql
80
+ note(noteId: ID!): Note @permission(name: "view", type: "Note", id: "args.noteId")
81
+ ```
82
+
83
+ ### ``@permission on <Type.field>: `id` reads "args.<name>", but the field declares <arguments>``
84
+
85
+ The path reads an argument the field does not have — usually the default
86
+ `args.id` on a field whose argument is named otherwise. graphql-js never puts
87
+ an undeclared argument on `args`, so every request would have failed. Name the
88
+ argument in `id`, or read the parent with `parent.<path>`.
89
+
90
+ In 2.x a `@check` booted with this and logged a warning; a `@permission`
91
+ refuses it.
92
+
93
+ ### ``@permission on <Type.field>: every term needs `namespace`, `permit` and `id`, got <term>``
94
+
95
+ `@permission(name: "", type: "Note")`: an empty `name` or `type`. Name both.
96
+
97
+ ### `@permission on <Type.field>: unknown namespace "<type>" — known: <namespaces>`
98
+
99
+ You passed `namespaces` to `useKetoChecks` / `applyKetoChecks`, and the field
100
+ names one outside them — most often a typo. Keto would have answered `false`
101
+ for ever, without an error. Fix the name, or add the namespace to the list
102
+ when the OPL document gained it.
103
+
104
+ ### `<Type.field>: @permission on an interface field guards nothing — no resolver runs there. Put it on each implementing type's field`
105
+
106
+ A resolver runs on an object type's field, never on an interface's. Move the
107
+ directive to every implementing type:
108
+
109
+ ```graphql
110
+ interface Node { id: ID! }
111
+ type Note implements Node {
112
+ id: ID!
113
+ body: String @permission(name: "view", type: "Note", id: "parent.id")
114
+ }
115
+ ```
116
+
117
+ In 2.x a `@check` there booted with a warning; a `@permission` is refused.
118
+
119
+ ### ``@authenticated on <where>: `type: []` admits no caller — name a type, or drop `type` ``
120
+
121
+ `<where>` is where the directive sits: `Type.field`, or a type, interface,
122
+ scalar or enum name.
123
+
124
+ An empty list admits nobody. Name the kinds of caller, or drop `type` to
125
+ admit any caller.
126
+
127
+ ### `@authenticated on <where>: unknown type "<type>" — known: <types>`
128
+
129
+ `type:` names a value outside the caller types `useAuthenticated` knows —
130
+ `session` and `token` by default. Fix the name, or list your own:
131
+
132
+ ```ts
133
+ useAuthenticated({ types: [...CALLER_TYPES, 'service'] });
134
+ ```
135
+
136
+ ### ``@authenticated on <Type.field>: the field's, its type's and its interfaces' `type`s have none in common — no caller could pass``
137
+
138
+ Every `@authenticated` that applies to a field must hold — the field's, its
139
+ type's, its interfaces'. Two of them name disjoint types, so no caller could
140
+ reach the field. Drop or widen one.
141
+
142
+ ### `Unknown argument "type" on directive "@authenticated".`
143
+
144
+ The schema declares `@authenticated` without an argument — federation's
145
+ declaration, imported through `@link`, or your own — and a field writes
146
+ `type:`. In a subgraph keep federation's form and drop `type:`; in a schema
147
+ of your own, load `SHARED_TYPE_DEFS` or `AUTHENTICATED_DIRECTIVE_SDL` in place
148
+ of your declaration.
149
+
150
+ ### `@nxgt/shared-graphql: no package root`
151
+
152
+ Thrown when the package is imported, if no `package.json` sits anywhere
153
+ above its files — a bundle copied into an image without one. The more common
154
+ case throws nothing: a bundler inlined the package into your app's bundle, the
155
+ walk finds your app's `package.json`, and `SHARED_SCHEMA_PATH` points at
156
+ `<app>/graphql/**`, which loads no SDL. Either way, mark
157
+ `@nxgt/shared-graphql` external in the bundler so it stays on disk as
158
+ installed.
159
+
160
+ ### `Directive "@permission" argument "name" of type "String!" is required, but it was not provided.`
161
+
162
+ On graphql 17: `Argument "@permission(name:)" of type "String!" is required, but it was not provided.`
163
+
164
+ Your schema declares its own `@permission`, and you load this package's
165
+ `graphql/**/*.graphqls` (`SHARED_SCHEMA_PATH`) beside it: `mergeTypeDefs`
166
+ merges the two declarations into one with both sets of arguments, and your
167
+ usages lack `name` and `type`. `@permission` and `PermissionDenial` are
168
+ names this package ships. Load only the folders you need —
169
+ `graphql/scalars`, `graphql/schema` — or rename your directive.
170
+ `useKetoChecks` itself leaves a `@permission` without `name` and `type`
171
+ alone.
172
+
173
+ ### `Cannot use GraphQLSchema "<schema>" from another module or realm.`
174
+
175
+ Two copies of `graphql` are installed: `graphql` is a peer of this package
176
+ (`^16.9.0 || ^17.0.0`), so your app must declare it once, and every GraphQL
177
+ library must resolve that one. Add `graphql` to your `dependencies` if it was
178
+ only there through this package, and check with `bun pm ls graphql` (or
179
+ `npm ls graphql`) that one version remains. graphql runs this check only
180
+ when `NODE_ENV` is not `production`; in production two copies fail later,
181
+ with less telling errors.
182
+
183
+ ## When a request runs
184
+
185
+ ### `<Type.field>: "<path>" resolved no object id`
186
+
187
+ A plain `Error` thrown inside a resolver: `createMaskError` answers the client
188
+ `Unexpected error.` (`INTERNAL_SERVER_ERROR`); the text is in the server log,
189
+ and in `extensions.debugMessage` when `isDev` is on.
190
+
191
+ The path was valid, but at request time it pointed at nothing — a nullable
192
+ argument nobody passed, an empty list, a parent field the parent resolver did
193
+ not load. It is a 500, never an allow. Make the argument required, or make
194
+ sure the parent object carries the field.
195
+
196
+ ### `<Type.field>: no checker on the context — useKetoChecks(ory) is not registered`
197
+
198
+ The schema was transformed with `applyKetoChecks` but the plugin that puts the
199
+ per-request checker on the context is missing. Register
200
+ `useKetoChecks(ory)` after `useOryAuth(ory)`. Answered as the previous entry.
201
+
202
+ ### `can(): no checker on the context — useKetoChecks(ory) is not registered`
203
+
204
+ The same, from `can`.
205
+
206
+ ### `ory: keto is unavailable` with `SERVICE_UNAVAILABLE` and HTTP 503
207
+
208
+ Keto (or Kratos, Hydra — the name varies) did not answer. This is on
209
+ purpose: an outage is never turned into a denial or an anonymous caller. The
210
+ client can retry; `extensions.debugMessage` carries what the SDK saw.
211
+
212
+ ### A denial's message reads `notes.errors.not-found`
213
+
214
+ A `@permission(message: "notes.errors.not-found")`, or your own
215
+ `denial(code, key)`, reached the client untranslated. A denial translates its
216
+ key with `@nxgt/i18n`'s own resources, which do not hold your app's keys.
217
+ Register the mask with your translator:
218
+
219
+ ```ts
220
+ createYoga({ maskedErrors: { maskError: createMaskError(translate) } });
221
+ ```
222
+
223
+ Under Apollo Server, the counterpart is `formatError: createFormatError(translate)`.
224
+
225
+ ### `Unexpected error.` with `INTERNAL_SERVER_ERROR` where a service's 404 or 403 was expected
226
+
227
+ A service threw a `CustomException` and `createMaskError` is not registered,
228
+ so Yoga masks it as any non-GraphQL error. The directives and `requireUser` /
229
+ `can` are not concerned — their denials carry their own status — but a
230
+ service's `CustomException.notFound()` is. Register `createMaskError`, as
231
+ above, or throw `denial(code, message)` from the resolver.
232
+
233
+ ### `Unexpected error.` where a resolver's own message used to reach the client
234
+
235
+ `createMaskError` masks a plain `Error` a resolver threw, as Yoga's default
236
+ does. Throw a `CustomException`, a `denial()` or a `GraphQLError` for a
237
+ message meant for the caller.
238
+
239
+ ## Traps that throw nothing
240
+
241
+ ### Every caller is anonymous behind the gateway
242
+
243
+ `useAuth({ trustedGateway })` reads no caller from a request that does not
244
+ carry the gateway's proof. The gateway is not sending the header — or sends
245
+ another secret — on its subgraph requests. Make it add
246
+ `x-gateway-secret: <secret>` (or your `header`) to every one, with the same
247
+ value the subgraph was given.
248
+
249
+ ### `FORBIDDEN` from `@authenticated(type:)` for a caller who should pass
250
+
251
+ The caller's type is `context.ory.kind`, or `context.user.tokenType` without
252
+ Ory. Behind a gateway that writes no `tokenType` in the `user` it forwards,
253
+ every caller has no type, and every `type:` refuses them. Make the gateway
254
+ write it, or drop `type:` on those fields.
255
+
256
+ ### A guarded field answers unguarded in the supergraph
257
+
258
+ A subgraph composed by federation drops a directive it was not told to keep.
259
+ Add `@composeDirective(name: "@permission")` and import the directive in the
260
+ subgraph's `@link`.
261
+
262
+ ### `@authenticated(type:)` in a subgraph
263
+
264
+ Federation declares `@authenticated` without an argument, and a subgraph
265
+ imports that declaration. Keep it: `useAuthenticated` enforces it as "any
266
+ caller". A `type:` restriction is for a schema that declares `@authenticated`
267
+ through `SHARED_TYPE_DEFS` or `AUTHENTICATED_DIRECTIVE_SDL`.
268
+
269
+ ### `OryForbidden` from `ory.requireAllowed` is a 500
270
+
271
+ `createMaskError` does not map `OryForbidden`: whether a refusal is a 404 or a
272
+ 403 is the caller's decision. In a resolver, use `can` and throw the
273
+ `denial` you mean.
@@ -0,0 +1,73 @@
1
+ # The flat form of @check, and the name to write in a new schema.
2
+ #
3
+ # One @permission is one Keto question — "may the caller `name` this `type`" —
4
+ # the same vocabulary as @nxgt/janus-graphql's @permission. Repeated, they are
5
+ # AND, evaluated in declaration order with @check, each with its own onDeny.
6
+ # An OR belongs in the Keto model (a permit that unions two relations), not in
7
+ # the schema; @check keeps the list-of-lists form for the schemas that still
8
+ # write one, and is deprecated in its favour.
9
+ #
10
+ # `useKetoChecks(ory)` answers both. The string export PERMISSION_DIRECTIVE_SDL
11
+ # carries the same declaration, and a spec holds the two equal.
12
+
13
+ """
14
+ What a refused @permission looks like from outside.
15
+ """
16
+ enum PermissionDenial {
17
+ """
18
+ The same answer as for an id that never existed, so ids cannot be probed.
19
+ The default.
20
+ """
21
+ NOT_FOUND
22
+
23
+ """
24
+ For a permission asked of an object the caller can already see: a viewer
25
+ asked to edit already knows it exists, and "you may not change it" is
26
+ honest.
27
+ """
28
+ FORBIDDEN
29
+ }
30
+
31
+ """
32
+ The permission a field requires: the caller must hold the permit `name` on the
33
+ `type` object whose id `id` points at.
34
+
35
+ Repeatable, and repeated ones are AND, evaluated in declaration order — the
36
+ 404-then-403 ladder:
37
+
38
+ updateNote(id: ID!, input: UpdateNoteInput!): Note!
39
+ @permission(name: "view", type: "Note")
40
+ @permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
41
+
42
+ NOT for a field that answers a LIST the caller is entitled to: fold a Keto
43
+ query into the database filter before the read instead.
44
+ """
45
+ directive @permission(
46
+ """
47
+ The permit asked of Keto, e.g. "view". A plain relation works too; Keto
48
+ answers `false`, not an error, for a name it does not know.
49
+ """
50
+ name: String!
51
+
52
+ """
53
+ The Keto namespace, e.g. "Note" — from the stack's OPL document.
54
+ """
55
+ type: String!
56
+
57
+ """
58
+ Where the object id is read: `args.<path>` or `parent.<path>`, dotted
59
+ paths allowed, `args.id` when omitted. A value that turns out to be a LIST
60
+ requires the permit on every element.
61
+ """
62
+ id: String
63
+
64
+ onDeny: PermissionDenial! = NOT_FOUND
65
+
66
+ """
67
+ The i18n key the denial carries, e.g. "notes.errors.not-found". Defaults to
68
+ `errors.not-found` / `errors.insufficient-permissions`, the shared keys.
69
+ Set it when the service layer words the same refusal with a domain message,
70
+ so the wording does not tell a caller which layer refused.
71
+ """
72
+ message: String
73
+ ) repeatable on FIELD_DEFINITION