@nxgt/shared-graphql 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +128 -73
  2. package/dist/directives/check.d.ts +13 -21
  3. package/dist/directives/check.d.ts.map +1 -1
  4. package/dist/directives/deprecation.d.ts +7 -0
  5. package/dist/directives/deprecation.d.ts.map +1 -0
  6. package/dist/directives/index.d.ts +5 -0
  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 +25 -0
  11. package/dist/directives/permission.d.ts.map +1 -0
  12. package/dist/directives/requirements.d.ts +14 -0
  13. package/dist/directives/requirements.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 +16 -0
  17. package/dist/directives/sdl.d.ts.map +1 -0
  18. package/dist/directives/validate.d.ts +29 -0
  19. package/dist/directives/validate.d.ts.map +1 -0
  20. package/dist/index.js +495 -139
  21. package/dist/index.js.map +21 -8
  22. package/dist/plugins/apply-keto-checks.d.ts +17 -0
  23. package/dist/plugins/apply-keto-checks.d.ts.map +1 -0
  24. package/dist/plugins/index.d.ts +3 -0
  25. package/dist/plugins/index.d.ts.map +1 -1
  26. package/dist/plugins/keto-checker.d.ts +24 -0
  27. package/dist/plugins/keto-checker.d.ts.map +1 -0
  28. package/dist/plugins/keto-checks.d.ts +8 -32
  29. package/dist/plugins/keto-checks.d.ts.map +1 -1
  30. package/dist/plugins/keto-helpers.d.ts +38 -0
  31. package/dist/plugins/keto-helpers.d.ts.map +1 -0
  32. package/dist/plugins/ory-auth.d.ts +2 -2
  33. package/dist/plugins/ory-auth.d.ts.map +1 -1
  34. package/dist/scalars/custom/utils.d.ts.map +1 -1
  35. package/dist/utils/errors/create-error.d.ts +3 -0
  36. package/dist/utils/errors/create-error.d.ts.map +1 -0
  37. package/dist/utils/errors/format-error.d.ts +11 -0
  38. package/dist/utils/errors/format-error.d.ts.map +1 -0
  39. package/dist/utils/errors/index.d.ts +5 -0
  40. package/dist/utils/errors/index.d.ts.map +1 -0
  41. package/dist/utils/errors/mask-error.d.ts +24 -0
  42. package/dist/utils/errors/mask-error.d.ts.map +1 -0
  43. package/dist/utils/errors/ory-unavailable.d.ts +19 -0
  44. package/dist/utils/errors/ory-unavailable.d.ts.map +1 -0
  45. package/dist/utils/index.d.ts +1 -1
  46. package/dist/utils/index.d.ts.map +1 -1
  47. package/docs/README.md +11 -0
  48. package/docs/guide/errors.md +104 -0
  49. package/docs/guide/permissions.md +231 -0
  50. package/docs/roadmap.md +70 -0
  51. package/docs/troubleshooting.md +170 -0
  52. package/graphql/directives/check.graphqls +6 -2
  53. package/graphql/directives/permission.graphqls +73 -0
  54. package/package.json +8 -6
  55. package/dist/utils/errors.utils.d.ts +0 -23
  56. package/dist/utils/errors.utils.d.ts.map +0 -1
@@ -0,0 +1,170 @@
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 schema is built are `TypeError`s naming
5
+ `Type.field`, and stop the server from booting. They start `@permission on`
6
+ or `@check on`, after the directive that carries the mistake; the entries show
7
+ one of the two. The last section holds the
8
+ traps that throw nothing.
9
+
10
+ ## When the schema is built
11
+
12
+ ### ``@permission on <Type.field>: `id` must be "args.<path>", "parent.<path>" or "source.<path>", got "<id>"``
13
+
14
+ The `id` argument (or a `@check` term's `id`) names no root. Prefix it:
15
+
16
+ ```graphql
17
+ note(noteId: ID!): Note @permission(name: "view", type: "Note", id: "args.noteId")
18
+ ```
19
+
20
+ ### ``@permission on <Type.field>: `id` reads "args.<name>", but the field declares <arguments>``
21
+
22
+ The path reads an argument the field does not have — usually the default
23
+ `args.id` on a field whose argument is named otherwise. graphql-js never puts
24
+ an undeclared argument on `args`, so every request would have failed. Name the
25
+ argument in `id`, or read the parent with `parent.<path>`.
26
+
27
+ ### `@check on <Type.field>: an empty group admits EVERYONE — a conjunction over no terms is true`
28
+
29
+ `@check(permissions: [[]])`. An AND over nothing is true, so the field would
30
+ be open to every signed-in caller while looking guarded. Name a permission,
31
+ or remove the directive.
32
+
33
+ ### `@check on <Type.field>: an empty permission requirement admits nobody — remove it, or name a permission`
34
+
35
+ `@check(permissions: [])`: an OR over nothing refuses everyone.
36
+
37
+ ### ``<…> — @check still boots with this; the next major refuses it, as @permission does``
38
+
39
+ A warning, not an error: one of the two mistakes above — an argument the
40
+ field does not declare, or a guard on an interface field — on a `@check`. The
41
+ server boots as it did in 2.x, but the field answers 500 on every request, or
42
+ is not guarded at all. Fix it as the entry for the same message describes; the
43
+ next major refuses it at build.
44
+
45
+ ### ``@check on <Type.field>: every term needs `namespace`, `permit` and `id`, got <term>``
46
+
47
+ A term, or a `@permission`, has an empty `namespace`/`type` or
48
+ `permit`/`name` — `@permission(name: "", type: "Note")`. Name both.
49
+
50
+ ### `@permission on <Type.field>: unknown namespace "<type>" — known: <namespaces>`
51
+
52
+ You passed `namespaces` to `useKetoChecks` / `applyKetoChecks`, and the field
53
+ names one outside them — most often a typo. Keto would have answered `false`
54
+ for ever, without an error. Fix the name, or add the namespace to the list
55
+ when the OPL document gained it.
56
+
57
+ ### `<Type.field>: @check / @permission on an interface field guards nothing — no resolver runs there. Put it on each implementing type's field`
58
+
59
+ A resolver runs on an object type's field, never on an interface's. Move the
60
+ directive to every implementing type:
61
+
62
+ ```graphql
63
+ interface Node { id: ID! }
64
+ type Note implements Node {
65
+ id: ID!
66
+ body: String @permission(name: "view", type: "Note", id: "parent.id")
67
+ }
68
+ ```
69
+
70
+ ### `@nxgt/shared-graphql: no package root`
71
+
72
+ Thrown when the package is imported, if no `package.json` sits anywhere
73
+ above its files — a bundle copied into an image without one. The more common
74
+ case throws nothing: a bundler inlined the package into your app's bundle, the
75
+ walk finds your app's `package.json`, and `SHARED_SCHEMA_PATH` points at
76
+ `<app>/graphql/**`, which loads no SDL. Either way, mark
77
+ `@nxgt/shared-graphql` external in the bundler so it stays on disk as
78
+ installed.
79
+
80
+ ### `Directive "@permission" argument "name" of type "String!" is required, but it was not provided.`
81
+
82
+ On graphql 17: `Argument "@permission(name:)" of type "String!" is required, but it was not provided.`
83
+
84
+ Your schema declares its own `@permission`, and you load this package's
85
+ `graphql/**/*.graphqls` (`SHARED_SCHEMA_PATH`) beside it: `mergeTypeDefs`
86
+ merges the two declarations into one with both sets of arguments, and your
87
+ usages lack `name` and `type`. `@permission` and `PermissionDenial` are
88
+ names this package ships. Load only the folders you need —
89
+ `graphql/scalars`, `graphql/schema` — or rename your directive.
90
+ `useKetoChecks` itself leaves a `@permission` without `name` and `type`
91
+ alone.
92
+
93
+ ### `Cannot use GraphQLSchema "<schema>" from another module or realm.`
94
+
95
+ Two copies of `graphql` are installed: `graphql` is a peer of this package
96
+ (`^16.4.2 || ^17.0.0`), so your app must declare it once, and every GraphQL
97
+ library must resolve that one. Add `graphql` to your `dependencies` if it was
98
+ only there through this package, and check with `bun pm ls graphql` (or
99
+ `npm ls graphql`) that one version remains. graphql runs this check only
100
+ when `NODE_ENV` is not `production`; in production two copies fail later,
101
+ with less telling errors.
102
+
103
+ ## When a request runs
104
+
105
+ The three messages below are plain `Error`s thrown inside a resolver:
106
+ `createMaskError` answers the client `Unexpected error.`
107
+ (`INTERNAL_SERVER_ERROR`). The text is in the server log, and in
108
+ `extensions.debugMessage` when `isDev` is on.
109
+
110
+ ### `<Type.field>: "<path>" resolved no object id`
111
+
112
+ The path was valid, but at request time it pointed at nothing — a nullable
113
+ argument nobody passed, an empty list, a parent field the parent resolver did
114
+ not load. It is a 500, never an allow. Make the argument required, or make
115
+ sure the parent object carries the field.
116
+
117
+ ### `<Type.field>: no checker on the context — useKetoChecks(ory) is not registered`
118
+
119
+ The schema was transformed with `applyKetoChecks` but the plugin that puts the
120
+ per-request checker on the context is missing. Register
121
+ `useKetoChecks(ory)` after `useOryAuth(ory)`.
122
+
123
+ ### `can(): no checker on the context — useKetoChecks(ory) is not registered`
124
+
125
+ The same, from `can`.
126
+
127
+ ### `ory: keto is unavailable` with `SERVICE_UNAVAILABLE` and HTTP 503
128
+
129
+ Keto (or Kratos, Hydra — the name varies) did not answer. This is on
130
+ purpose: an outage is never turned into a denial or an anonymous caller. The
131
+ client can retry; `extensions.debugMessage` carries what the SDK saw.
132
+
133
+ ### `Unexpected error.` with `INTERNAL_SERVER_ERROR` where a 404 or 403 was expected
134
+
135
+ `createMaskError` is not registered, so Yoga masks the `CustomException` a
136
+ denial throws as it masks any non-GraphQL error:
137
+
138
+ ```ts
139
+ createYoga({ maskedErrors: { maskError: createMaskError(translate) } });
140
+ ```
141
+
142
+ Under Apollo Server, the counterpart is `formatError: createFormatError(translate)`.
143
+
144
+ ### `Unexpected error.` where a resolver's own message used to reach the client
145
+
146
+ Since this release `createMaskError` masks a plain `Error` a resolver threw,
147
+ as Yoga's default does — before, its message (a driver error, a host name)
148
+ reached the client. Throw a `CustomException` or a `GraphQLError` for a
149
+ message meant for the caller.
150
+
151
+ ## Traps that throw nothing
152
+
153
+ ### A guarded field answers unguarded in the supergraph
154
+
155
+ A subgraph composed by federation drops a directive it was not told to keep.
156
+ Add `@composeDirective(name: "@permission")` (and `"@check"` if used) and
157
+ import the directive in the subgraph's `@link`.
158
+
159
+ ### `useAuth()` trusts the request body
160
+
161
+ `useAuth()` copies `user` and `token` from the GraphQL request's
162
+ `extensions`, which a client writes. It is safe only behind a gateway that
163
+ sets them and a network that stops callers from reaching the service
164
+ directly. An API that resolves its own callers uses `useOryAuth(ory)`.
165
+
166
+ ### `OryForbidden` from `ory.requireAllowed` is a 500
167
+
168
+ `createMaskError` does not map `OryForbidden`: whether a refusal is a 404 or a
169
+ 403 is the caller's decision. In a resolver, use `can` and throw the
170
+ `CustomException` you mean.
@@ -25,8 +25,8 @@ input CheckPermission {
25
25
  permit: String!
26
26
 
27
27
  """
28
- Where the object id is read: `args.<path>` or `source.<path>`, dotted paths
29
- allowed. A value that turns out to be a LIST requires the permit on every
28
+ Where the object id is read: `args.<path>`, or `parent.<path>` /
29
+ `source.<path>` (the same root under two names), dotted paths allowed. A value that turns out to be a LIST requires the permit on every
30
30
  element — that is what a mutation taking `ids: [ID!]!` means.
31
31
  """
32
32
  id: String = "args.id"
@@ -51,6 +51,10 @@ enum CheckDenial {
51
51
  }
52
52
 
53
53
  """
54
+ Deprecated: write `@permission(name:, type:)` in a new schema — one Keto
55
+ question per directive, repeated for AND, with OR kept in the Keto model. This
56
+ form keeps working and is evaluated in declaration order with @permission.
57
+
54
58
  The permission a field requires, in disjunctive normal form: the OUTER list is
55
59
  OR, the INNER list is AND. `[[A, B], [C]]` reads "(A and B) or C" — the same
56
60
  shape `@policy(policies: [["ADMIN"]])` uses.
@@ -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
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@nxgt/shared-graphql",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "files": [
9
9
  "dist",
10
+ "docs",
10
11
  "graphql",
11
12
  "README.md",
12
13
  "package.json",
@@ -56,14 +57,13 @@
56
57
  "@graphql-tools/utils": "^12.0.0",
57
58
  "@graphql-yoga/redis-event-target": "^3.0.3",
58
59
  "@hono/zod-validator": "^0.9.0",
59
- "@nxgt/i18n": "^1.0.4",
60
- "@nxgt/security": "^4.0.0",
61
- "@nxgt/shared": "^1.0.4",
60
+ "@nxgt/i18n": "^1.1.0",
61
+ "@nxgt/security": "^4.0.1",
62
+ "@nxgt/shared": "^1.0.5",
62
63
  "@nxgt/shared-exceptions": "^1.0.4",
63
64
  "@nxgt/shared-logging": "^1.0.4",
64
65
  "@nxgt/shared-mongo": "^1.1.4",
65
66
  "dataloader": "^2.2.3",
66
- "graphql": "^16.4.2",
67
67
  "graphql-scalars": "^1.26.0",
68
68
  "graphql-subscriptions": "^3.0.0",
69
69
  "graphql-upload": "^18.0.0",
@@ -79,10 +79,12 @@
79
79
  "@types/bun": "^1.4.0",
80
80
  "@types/graphql-upload": "^17.0.0",
81
81
  "@types/lodash": "^4.17.25",
82
- "@types/ws": "^8.18.1"
82
+ "@types/ws": "^8.18.1",
83
+ "graphql": "^16.4.2"
83
84
  },
84
85
  "peerDependencies": {
85
86
  "@nxgt/ory-sdk": ">=0.1.0",
87
+ "graphql": "^16.4.2 || ^17.0.0",
86
88
  "stx-sdk": ">=1.1.0",
87
89
  "typescript": "^6.0.3"
88
90
  }
@@ -1,23 +0,0 @@
1
- import type { ApolloServerOptions } from '@apollo/server';
2
- import { type LocaleKey, type TranslationContext } from '@nxgt/i18n';
3
- import { GraphQLError, type GraphQLErrorOptions } from 'graphql';
4
- import type { MaskError } from 'graphql-yoga';
5
- import type { GraphQLBaseContext } from '../types';
6
- export declare function createGraphQLError(message: string, options?: GraphQLErrorOptions): GraphQLError;
7
- export declare function createFormatError<K extends LocaleKey, C extends GraphQLBaseContext = GraphQLBaseContext>(translate?: (message: K, context?: TranslationContext) => string, production?: boolean): ApolloServerOptions<C>['formatError'];
8
- /**
9
- * Yoga's `maskedErrors.maskError`, taught this repo's exceptions.
10
- *
11
- * Yoga masks anything that is not a `GraphQLError` as "Unexpected error." —
12
- * and `CustomException` extends `Error`, so out of the box a service's
13
- * `notFound()` or `forbidden()` reaches the client as an opaque 500. This
14
- * turns one into a `GraphQLError` with the translated message, the
15
- * exception's `code` in `extensions`, and a matching HTTP status; a Mongoose
16
- * error goes through `castError` first, as `createFormatError` does for
17
- * Apollo. `OryUnavailable` (@nxgt/ory-sdk) becomes a 503 `SERVICE_UNAVAILABLE`,
18
- * never a denial. Everything else is masked exactly as before.
19
- *
20
- * createYoga({ maskedErrors: { maskError: createMaskError(translate) } })
21
- */
22
- export declare function createMaskError<K extends LocaleKey>(translate?: (message: K, context?: TranslationContext) => string): MaskError;
23
- //# sourceMappingURL=errors.utils.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors.utils.d.ts","sourceRoot":"","sources":["../../src/utils/errors.utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAK1D,OAAO,EACN,KAAK,SAAS,EACd,KAAK,kBAAkB,EAEvB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,SAAS,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAEnD,wBAAgB,kBAAkB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,mBAAmB,GAC3B,YAAY,CAEd;AAED,wBAAgB,iBAAiB,CAChC,CAAC,SAAS,SAAS,EACnB,CAAC,SAAS,kBAAkB,GAAG,kBAAkB,EAEjD,SAAS,GAAE,CACV,OAAO,EAAE,CAAC,EACV,OAAO,CAAC,EAAE,kBAAkB,KACxB,MAAsB,EAC3B,UAAU,CAAC,EAAE,OAAO,GAClB,mBAAmB,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAoDvC;AAYD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,SAAS,EAClD,SAAS,GAAE,CACV,OAAO,EAAE,CAAC,EACV,OAAO,CAAC,EAAE,kBAAkB,KACxB,MAAsB,GACzB,SAAS,CAkDX"}