@nxgt/shared-graphql 2.0.0 → 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.
- package/README.md +128 -73
- package/dist/directives/check.d.ts +13 -21
- package/dist/directives/check.d.ts.map +1 -1
- package/dist/directives/deprecation.d.ts +7 -0
- package/dist/directives/deprecation.d.ts.map +1 -0
- package/dist/directives/index.d.ts +5 -0
- package/dist/directives/index.d.ts.map +1 -1
- package/dist/directives/paths.d.ts +20 -0
- package/dist/directives/paths.d.ts.map +1 -0
- package/dist/directives/permission.d.ts +25 -0
- package/dist/directives/permission.d.ts.map +1 -0
- package/dist/directives/requirements.d.ts +14 -0
- package/dist/directives/requirements.d.ts.map +1 -0
- package/dist/directives/scope.d.ts +19 -0
- package/dist/directives/scope.d.ts.map +1 -0
- package/dist/directives/sdl.d.ts +16 -0
- package/dist/directives/sdl.d.ts.map +1 -0
- package/dist/directives/validate.d.ts +29 -0
- package/dist/directives/validate.d.ts.map +1 -0
- package/dist/index.js +495 -139
- package/dist/index.js.map +21 -8
- package/dist/plugins/apply-keto-checks.d.ts +17 -0
- package/dist/plugins/apply-keto-checks.d.ts.map +1 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/keto-checker.d.ts +24 -0
- package/dist/plugins/keto-checker.d.ts.map +1 -0
- package/dist/plugins/keto-checks.d.ts +8 -32
- package/dist/plugins/keto-checks.d.ts.map +1 -1
- package/dist/plugins/keto-helpers.d.ts +38 -0
- package/dist/plugins/keto-helpers.d.ts.map +1 -0
- package/dist/plugins/ory-auth.d.ts +2 -2
- package/dist/plugins/ory-auth.d.ts.map +1 -1
- package/dist/scalars/custom/utils.d.ts.map +1 -1
- package/dist/utils/errors/create-error.d.ts +3 -0
- package/dist/utils/errors/create-error.d.ts.map +1 -0
- package/dist/utils/errors/format-error.d.ts +11 -0
- package/dist/utils/errors/format-error.d.ts.map +1 -0
- package/dist/utils/errors/index.d.ts +5 -0
- package/dist/utils/errors/index.d.ts.map +1 -0
- package/dist/utils/errors/mask-error.d.ts +24 -0
- package/dist/utils/errors/mask-error.d.ts.map +1 -0
- package/dist/utils/errors/ory-unavailable.d.ts +19 -0
- package/dist/utils/errors/ory-unavailable.d.ts.map +1 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/docs/README.md +11 -0
- package/docs/guide/errors.md +104 -0
- package/docs/guide/permissions.md +231 -0
- package/docs/roadmap.md +70 -0
- package/docs/troubleshooting.md +170 -0
- package/graphql/directives/check.graphqls +6 -2
- package/graphql/directives/permission.graphqls +73 -0
- package/package.json +11 -9
- package/dist/utils/errors.utils.d.ts +0 -23
- 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,
|
|
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,
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|
package/docs/roadmap.md
ADDED
|
@@ -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`.
|