@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
@@ -1 +1 @@
1
- {"version":3,"file":"ory-auth.d.ts","sourceRoot":"","sources":["../../src/plugins/ory-auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,GAAG,EACR,KAAK,YAAY,EACjB,cAAc,EACd,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,MAAM,UAAU,GAAG;IACxB,GAAG,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IAC1B;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;CACtB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,YAAY,GAAG,cAAc,CAc7D;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,cAAc,GAAG,YAAY,CAQvE;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACxC,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,UAAU,GAAG;IAAE,IAAI,CAAC,EAAE,cAAc,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAejE;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC,kBAAkB,GAAG,UAAU,CAAC,CAM5E"}
1
+ {"version":3,"file":"ory-auth.d.ts","sourceRoot":"","sources":["../../src/plugins/ory-auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,GAAG,EACR,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAMnD;;;;;GAKG;AACH,MAAM,MAAM,UAAU,GAAG;IACxB,GAAG,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IAC1B;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;CACtB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,YAAY,GAAG,cAAc,CAc7D;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,cAAc,GAAG,YAAY,CAEvE;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACxC,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,UAAU,GAAG;IAAE,IAAI,CAAC,EAAE,cAAc,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAejE;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC,kBAAkB,GAAG,UAAU,CAAC,CAM5E"}
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/scalars/custom/utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAE5C,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,CAAC,EACpC,MAAM,EAAE,iBAAiB,CAAC,CAAC,EAAE,CAAC,CAAC,EAC/B,EACC,IAAI,EACJ,WAAW,EACX,YAAY,GACZ,EAAE;IACF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;CAClC,2BA2CD"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/scalars/custom/utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAE5C,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,CAAC,EACpC,MAAM,EAAE,iBAAiB,CAAC,CAAC,EAAE,CAAC,CAAC,EAC/B,EACC,IAAI,EACJ,WAAW,EACX,YAAY,GACZ,EAAE;IACF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;CAClC,2BA4CD"}
@@ -0,0 +1,3 @@
1
+ import { GraphQLError, type GraphQLErrorOptions } from 'graphql';
2
+ export declare function createGraphQLError(message: string, options?: GraphQLErrorOptions): GraphQLError;
3
+ //# sourceMappingURL=create-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-error.d.ts","sourceRoot":"","sources":["../../../src/utils/errors/create-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAEjE,wBAAgB,kBAAkB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,mBAAmB,GAC3B,YAAY,CAEd"}
@@ -0,0 +1,11 @@
1
+ import type { ApolloServerOptions } from '@apollo/server';
2
+ import { type LocaleKey, type TranslationContext } from '@nxgt/i18n';
3
+ import type { GraphQLBaseContext } from '../../types';
4
+ /**
5
+ * Apollo Server's `formatError`, taught this repo's exceptions — the Apollo
6
+ * counterpart of `createMaskError`. `OryUnavailable` answers
7
+ * `SERVICE_UNAVAILABLE`, never a denial and never an opaque
8
+ * `INTERNAL_SERVER_ERROR`.
9
+ */
10
+ export declare function createFormatError<K extends LocaleKey, C extends GraphQLBaseContext = GraphQLBaseContext>(translate?: (message: K, context?: TranslationContext) => string, production?: boolean): ApolloServerOptions<C>['formatError'];
11
+ //# sourceMappingURL=format-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-error.d.ts","sourceRoot":"","sources":["../../../src/utils/errors/format-error.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,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAGtD;;;;;GAKG;AACH,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,CA4DvC"}
@@ -0,0 +1,5 @@
1
+ export * from './create-error';
2
+ export * from './format-error';
3
+ export * from './mask-error';
4
+ export * from './ory-unavailable';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/errors/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC"}
@@ -0,0 +1,24 @@
1
+ import { type LocaleKey, type TranslationContext } from '@nxgt/i18n';
2
+ import type { MaskError } from 'graphql-yoga';
3
+ type Translate<K extends LocaleKey> = (message: K, context?: TranslationContext) => string;
4
+ /**
5
+ * Yoga's `maskedErrors.maskError`, taught this repo's exceptions.
6
+ *
7
+ * Yoga masks anything that is not a `GraphQLError` as "Unexpected error." —
8
+ * and `CustomException` extends `Error`, so out of the box a service's
9
+ * `notFound()` or `forbidden()` reaches the client as an opaque 500. This
10
+ * turns one into a `GraphQLError` with the translated message, the
11
+ * exception's `code` in `extensions`, and a matching HTTP status
12
+ * (`UNAUTHENTICATED` 401, `FORBIDDEN` 403, `NOT_FOUND` 404); a Mongoose error
13
+ * goes through `castError` first, as `createFormatError` does for Apollo.
14
+ * `OryUnavailable` (@nxgt/ory-sdk) becomes a 503 `SERVICE_UNAVAILABLE`, never
15
+ * a denial. Everything else is masked as Yoga's default masks it: a
16
+ * `GraphQLError` thrown on purpose — or raised by validation — passes, and one
17
+ * that only wraps a plain `Error` a resolver threw is replaced by `message`,
18
+ * so an internal message never reaches the client.
19
+ *
20
+ * createYoga({ maskedErrors: { maskError: createMaskError(translate) } })
21
+ */
22
+ export declare function createMaskError<K extends LocaleKey>(translate?: Translate<K>): MaskError;
23
+ export {};
24
+ //# sourceMappingURL=mask-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mask-error.d.ts","sourceRoot":"","sources":["../../../src/utils/errors/mask-error.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,SAAS,EACd,KAAK,kBAAkB,EAEvB,MAAM,YAAY,CAAC;AAIpB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C,KAAK,SAAS,CAAC,CAAC,SAAS,SAAS,IAAI,CACrC,OAAO,EAAE,CAAC,EACV,OAAO,CAAC,EAAE,kBAAkB,KACxB,MAAM,CAAC;AAEZ;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,SAAS,EAClD,SAAS,GAAE,SAAS,CAAC,CAAC,CAAiB,GACrC,SAAS,CA2BX"}
@@ -0,0 +1,19 @@
1
+ import type { OryUnavailable } from '@nxgt/ory-sdk';
2
+ import { GraphQLError } from 'graphql';
3
+ /**
4
+ * Whether an error is `@nxgt/ory-sdk`'s `OryUnavailable` — Kratos, Hydra or
5
+ * Keto could not answer.
6
+ *
7
+ * By name and shape as well as by class: an install that resolves two copies
8
+ * of `@nxgt/ory-sdk` (one under this package, one under the app) throws an
9
+ * `OryUnavailable` this module's `instanceof` does not recognise, and an
10
+ * outage that slips past here is masked as a 500 instead of answered as a 503.
11
+ */
12
+ export declare function isOryUnavailable(error: unknown): error is OryUnavailable;
13
+ /**
14
+ * A 503 the client can read as one — a real `GraphQLError` so Yoga's masking
15
+ * leaves it alone, `extensions.http.status` so the transport says 503 too.
16
+ * Never a denial: an outage must not read as "not signed in" or "not allowed".
17
+ */
18
+ export declare function serviceUnavailableError(error: OryUnavailable): GraphQLError;
19
+ //# sourceMappingURL=ory-unavailable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ory-unavailable.d.ts","sourceRoot":"","sources":["../../../src/utils/errors/ory-unavailable.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvC;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAIxE;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,cAAc,GAAG,YAAY,CAQ3E"}
@@ -1,7 +1,7 @@
1
1
  export * from './change-resolvers.utils';
2
2
  export * from './codegen.utils';
3
3
  export * from './data-loader.utils';
4
- export * from './errors.utils';
4
+ export * from './errors';
5
5
  export * from './publish';
6
6
  export * from './sandbox';
7
7
  export * from './schema.utils';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC;AACzC,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC;AACzC,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC;AACpC,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC"}
package/docs/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # `@nxgt/shared-graphql` docs
2
+
3
+ The package's [README](../README.md) is the short version, with one example
4
+ per section. These pages are the long one.
5
+
6
+ - [Guide: permissions](./guide/permissions.md) — `@permission`, `@check`,
7
+ `useKetoChecks`, build-time validation, `requireUser` and `can`
8
+ - [Guide: errors](./guide/errors.md) — `createMaskError`, `createFormatError`,
9
+ the codes and statuses, and why an outage is a 503
10
+ - [Troubleshooting](./troubleshooting.md) — each error by the message you see
11
+ - [Roadmap](./roadmap.md) — what is next, including the planned major
@@ -0,0 +1,104 @@
1
+ # Errors: `createMaskError` and `createFormatError`
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.
8
+
9
+ ## Yoga
10
+
11
+ ```ts
12
+ import { createMaskError } from '@nxgt/shared-graphql';
13
+ import { translate } from './i18n';
14
+
15
+ createYoga({ maskedErrors: { maskError: createMaskError(translate) } });
16
+ ```
17
+
18
+ ## Apollo Server
19
+
20
+ ```ts
21
+ import { createFormatError } from '@nxgt/shared-graphql';
22
+
23
+ new ApolloServer({ formatError: createFormatError(translate, isProduction) });
24
+ ```
25
+
26
+ ## What a client receives — Yoga, through `createMaskError`
27
+
28
+ | Thrown | `extensions.code` | `extensions.http.status` | message |
29
+ | --- | --- | --- | --- |
30
+ | `CustomException.unauthenticated()` | `UNAUTHENTICATED` | 401 | translated key |
31
+ | `CustomException.forbidden()` | `FORBIDDEN` | 403 | translated key |
32
+ | `CustomException.notFound()` | `NOT_FOUND` | 404 | translated key |
33
+ | any other `CustomException` | its `errorCode` | its status | translated key |
34
+ | a Mongoose error | through `castError` | its status | translated key |
35
+ | `OryUnavailable` | `SERVICE_UNAVAILABLE` | 503 | `ory: keto is unavailable` |
36
+ | a `GraphQLError` thrown on purpose | its own | its own | its own |
37
+ | a plain `Error` a resolver threw | `INTERNAL_SERVER_ERROR` | — | the mask message |
38
+
39
+ ```json
40
+ {
41
+ "errors": [
42
+ {
43
+ "message": "Could not find the requested resource.",
44
+ "path": ["note"],
45
+ "extensions": { "code": "NOT_FOUND", "http": { "status": 404 } }
46
+ }
47
+ ]
48
+ }
49
+ ```
50
+
51
+ The HTTP status applies when it is the only error of the response, as Yoga
52
+ decides it.
53
+
54
+ ## What a client receives — Apollo, through `createFormatError`
55
+
56
+ | Thrown | `extensions.code` | message |
57
+ | --- | --- | --- |
58
+ | a `CustomException` or a Mongoose error | its `errorCode` (plus `debugMessage`) | translated key |
59
+ | `OryUnavailable` | `SERVICE_UNAVAILABLE`, `http: { status: 503 }` in `extensions` | `ory: keto is unavailable` |
60
+ | an Apollo validation or parse error | its own | translated `errors.<code>` |
61
+ | anything else | as Apollo formats it | as Apollo formats it — not masked here |
62
+
63
+ `formatError` shapes the error body; it does not set the response's status,
64
+ so `http.status` is information for the client, not the transport's answer.
65
+ The stack trace is removed when the second argument, `production`, is true.
66
+
67
+ ## An outage is a 503, never a denial
68
+
69
+ Kratos, Hydra or Keto failing to answer is not "anonymous" and not "denied".
70
+ Answering it as either would lock every caller out in silence — or, for a
71
+ check that defaulted open, let everyone in. So `OryUnavailable` is never caught
72
+ as a refusal anywhere in this package, and both functions answer it
73
+ `SERVICE_UNAVAILABLE` — with HTTP 503 under Yoga — which a client can retry.
74
+
75
+ It is recognised by class, and also by its name and shape: an install that
76
+ ends up with two copies of `@nxgt/ory-sdk` throws an `OryUnavailable` whose
77
+ class is not the one this package imported, and that must still be a 503
78
+ rather than a masked 500. `isOryUnavailable(error)` is the test, and
79
+ `serviceUnavailableError(error)` builds the 503 `GraphQLError` both use —
80
+ exported for a server that writes its own `maskError`:
81
+
82
+ ```ts
83
+ import { isOryUnavailable, serviceUnavailableError } from '@nxgt/shared-graphql';
84
+ import { GraphQLError } from 'graphql';
85
+ import type { MaskError } from 'graphql-yoga';
86
+
87
+ const maskError: MaskError = (error, message) => {
88
+ const original = error instanceof GraphQLError ? error.originalError : error;
89
+ if (isOryUnavailable(original)) return serviceUnavailableError(original);
90
+ // …
91
+ };
92
+ ```
93
+
94
+ `oryUnavailableError`, which `useOryAuth` throws when it cannot resolve the
95
+ caller, is the same function under its older name.
96
+
97
+ ## Internal messages stay internal
98
+
99
+ graphql-js wraps every error a resolver throws in a `GraphQLError`. A wrapper
100
+ around a plain `Error` — a driver's `connect ECONNREFUSED 10.0.0.5:27017` — is
101
+ replaced by the mask message, as Yoga's default mask does; its `path` is kept.
102
+ In development (`isDev`) the original is in `extensions.debugMessage`.
103
+
104
+ A `GraphQLError` you throw yourself, and every validation error, passes as is.
@@ -0,0 +1,231 @@
1
+ # Permissions: `@permission`, `@check`, `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
+ 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.
39
+
40
+ A schema assembled in code, with no `SHARED_SCHEMA_PATH`, takes the
41
+ declarations as a string:
42
+
43
+ ```ts
44
+ import { KETO_DIRECTIVES_SDL } from '@nxgt/shared-graphql';
45
+
46
+ createSchema({ typeDefs: [KETO_DIRECTIVES_SDL, typeDefs], resolvers });
47
+ ```
48
+
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
+
52
+ ## `@permission`
53
+
54
+ ```graphql
55
+ directive @permission(
56
+ name: String!
57
+ type: String!
58
+ id: String
59
+ onDeny: PermissionDenial! = NOT_FOUND
60
+ message: String
61
+ ) repeatable on FIELD_DEFINITION
62
+ ```
63
+
64
+ | Argument | Meaning |
65
+ | --- | --- |
66
+ | `name` | the permit asked of Keto — `view`, `edit`; a plain relation works too |
67
+ | `type` | the Keto namespace — `Note` |
68
+ | `id` | where the object id is read: `args.<path>` or `parent.<path>`; `args.id` when omitted |
69
+ | `onDeny` | `NOT_FOUND` (default) or `FORBIDDEN` |
70
+ | `message` | the i18n key the denial carries |
71
+
72
+ ### The 404-then-403 ladder
73
+
74
+ Repeated `@permission`s are AND, evaluated **in declaration order**, and each
75
+ answers its own `onDeny`:
76
+
77
+ ```graphql
78
+ updateNote(id: ID!, input: UpdateNoteInput!): Note!
79
+ @permission(name: "view", type: "Note")
80
+ @permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
81
+ ```
82
+
83
+ | Caller holds | Answer |
84
+ | --- | --- |
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
+ | `view` and `edit` | the resolver runs |
88
+
89
+ ### Where the id comes from
90
+
91
+ ```graphql
92
+ note(id: ID!): Note @permission(name: "view", type: "Note")
93
+ noteByKey(key: ID!): Note @permission(name: "view", type: "Note", id: "args.key")
94
+ deleteNotes(ids: [ID!]!): Boolean @permission(name: "delete", type: "Note", id: "args.ids")
95
+
96
+ type Note {
97
+ ownerId: ID!
98
+ owner: Person @permission(name: "view", type: "Person", id: "parent.ownerId")
99
+ }
100
+ ```
101
+
102
+ A **list** requires the permit on every element, and stops at the first
103
+ refusal. `source.<path>` is accepted as the same root as `parent.<path>`.
104
+
105
+ A path that resolves no id at request time — a nullable argument nobody
106
+ passed, a parent field that was not loaded — is a wiring error (500), never an
107
+ allow.
108
+
109
+ ### Or, in the model
110
+
111
+ There is no OR between `@permission`s. When a field is open to owners *or*
112
+ editors, say so in the Keto model — a permit that unions the two relations —
113
+ and ask that permit.
114
+
115
+ ### Wording a refusal
116
+
117
+ `message` defaults to the shared `errors.not-found` /
118
+ `errors.insufficient-permissions`. Set it whenever the service behind the field
119
+ refuses the same object with a domain message:
120
+
121
+ ```graphql
122
+ note(id: ID!): Note
123
+ @permission(name: "view", type: "Note", message: "notes.errors.not-found")
124
+ ```
125
+
126
+ Two layers guard these fields — the directive and the service's own access
127
+ check — and if they word the same 404 differently, the wording tells a caller
128
+ which one refused: a generic message means "you may not", a domain one "it is
129
+ gone". That is the distinction `NOT_FOUND` exists to hide.
130
+
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:
150
+
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.
159
+
160
+ ## Refused at build
161
+
162
+ `applyKetoChecks` — which `useKetoChecks` runs on every schema change — reads
163
+ every requirement when the schema is built, and throws a `TypeError` naming
164
+ `Type.field` for:
165
+
166
+ | Mistake | Message starts |
167
+ | --- | --- |
168
+ | a path naming no root | ``@permission on Query.note: `id` must be "args.<path>", …`` |
169
+ | 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 …` |
171
+ | 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.
179
+
180
+ `namespaces` is optional. Without it a namespace is taken on trust, and a
181
+ misspelt one answers `false` for ever — Keto does not error on a namespace it
182
+ does not know. Pass the namespaces of your OPL document to make that a boot
183
+ failure.
184
+
185
+ ## From a resolver
186
+
187
+ ```ts
188
+ import { can, type OryGraphQLContext, requireUser } from '@nxgt/shared-graphql';
189
+
190
+ const resolvers = {
191
+ Mutation: {
192
+ archive: async (_: unknown, { id }: { id: string }, ctx: OryGraphQLContext) => {
193
+ const user = requireUser(ctx);
194
+ if (!(await can(ctx, { name: 'edit', type: 'Note', id }))) {
195
+ throw CustomException.forbidden({ message: 'notes.errors.read-only' });
196
+ }
197
+ return notes.archive(id, user.sub);
198
+ },
199
+ },
200
+ };
201
+ ```
202
+
203
+ - `requireUser(ctx)` returns `ctx.user`, or throws `UNAUTHENTICATED` (401).
204
+ - `can(ctx, { name, type, id })` returns Keto's answer. It throws
205
+ `UNAUTHENTICATED` with no caller, names the missing plugin without
206
+ `useKetoChecks`, and lets `OryUnavailable` through on an outage — it never
207
+ answers `false` for a question it could not ask.
208
+
209
+ ## The per-request memo
210
+
211
+ `useKetoChecks` puts one `ketoChecks` function on each request's context. It
212
+ **batches** the distinct questions of one tick into a single
213
+ `POST /relation-tuples/batch/check` and **memoises** answers for the request.
214
+ A field guarded by `@permission(view)` and a service that asks the same
215
+ question through `can` pay for one round trip between them.
216
+
217
+ A batch that failed is not remembered: every question in it rejects with the
218
+ outage, and the next ask goes back to Keto.
219
+
220
+ ## An outage is not a no
221
+
222
+ `@nxgt/ory-sdk` throws `OryUnavailable` when Keto cannot answer. Nothing in
223
+ this package catches it as a denial: the directive, `can` and the memo all
224
+ let it through, and `createMaskError` / `createFormatError` answer it
225
+ `SERVICE_UNAVAILABLE` with HTTP 503. See [errors](./errors.md).
226
+
227
+ ## Not a check: lists
228
+
229
+ "Which notes may I see" is a Keto query folded into the database filter before
230
+ the read, not a directive. A directive on a list field would have to fetch
231
+ everything and filter after, which makes `totalCount` and the cursors lie.
@@ -0,0 +1,70 @@
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` and `useKetoChecks` for an
9
+ Ory-native API, `createMaskError` / `createFormatError`, dataloaders,
10
+ subscriptions over Redis, uploads and the Hono integration.
11
+
12
+ ## Next — a planned major
13
+
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.
39
+
40
+ ## Later
41
+
42
+ - **Batching a list argument's ids** into one Keto batch rather than one
43
+ question per id in order.
44
+
45
+ ## Not planned
46
+
47
+ - **A directive on a list field** that filters after the read. "Which objects
48
+ may I see" is a Keto query folded into the database filter.
49
+ - **OR between directives.** It belongs in the Keto model.
50
+ - **Turning an Ory outage into a denial or an anonymous caller**, under any
51
+ option. It is a 503.
52
+
53
+ ## Shipped
54
+
55
+ - **`@permission(name, type, id, onDeny, message)`**, the flat form, answered
56
+ with `@check` in declaration order; `@check` deprecated in its favour.
57
+ - **More refused at build**: an argument the field does not declare, a
58
+ namespace outside the model (`namespaces`), a guard on an interface field —
59
+ each a `TypeError` naming the field for `@permission`, and a warning for
60
+ `@check`, which booted with the first and last before.
61
+ - **`requireUser`, `can` and `OryGraphQLContext`** for resolvers, through the
62
+ same per-request memo.
63
+ - **The directive SDL as strings** — `PERMISSION_DIRECTIVE_SDL`,
64
+ `CHECK_DIRECTIVE_SDL`, `KETO_DIRECTIVES_SDL` — held equal to the files.
65
+ - **graphql 17**: `graphql` is a peer, `^16.4.2 || ^17.0.0`, and the suite
66
+ runs on both.
67
+ - **An outage under Apollo is a 503 too**, and one from a second copy of
68
+ `@nxgt/ory-sdk` is still recognised.
69
+ - **Internal messages masked**: a plain `Error` a resolver threw no longer
70
+ reaches the client through `createMaskError`.