@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.
- 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 +8 -6
- package/dist/utils/errors.utils.d.ts +0 -23
- package/dist/utils/errors.utils.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -5,31 +5,28 @@ export * from "graphql-subscriptions";
|
|
|
5
5
|
|
|
6
6
|
// src/directives/check.ts
|
|
7
7
|
import { getDirective } from "@graphql-tools/utils";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from "@nxgt/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const found = getDirective(schema, node, CHECK_DIRECTIVE_NAME) ?? [];
|
|
14
|
-
return found.map((raw) => {
|
|
15
|
-
const permissions = raw.permissions ?? [];
|
|
16
|
-
assertRequirement(permissions, `@check on ${where}`);
|
|
17
|
-
for (const group of permissions) {
|
|
18
|
-
for (const term of group)
|
|
19
|
-
assertReadablePath(term, where);
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
22
|
-
permissions,
|
|
23
|
-
onDeny: raw.onDeny ?? "NOT_FOUND",
|
|
24
|
-
message: raw.message
|
|
25
|
-
};
|
|
26
|
-
});
|
|
8
|
+
|
|
9
|
+
// src/directives/deprecation.ts
|
|
10
|
+
import { logger } from "@nxgt/shared-logging";
|
|
11
|
+
function warnCheckMistake(message) {
|
|
12
|
+
logger.warn(`${message} — @check still boots with this; the next major refuses it, as @permission does`);
|
|
27
13
|
}
|
|
14
|
+
|
|
15
|
+
// src/directives/paths.ts
|
|
16
|
+
var PATH = /^(args|parent|source)(\.[A-Za-z0-9_]+)+$/;
|
|
17
|
+
var DEFAULT_ID_PATH = "args.id";
|
|
28
18
|
function assertReadablePath(term, where) {
|
|
29
|
-
if (
|
|
30
|
-
throw new
|
|
19
|
+
if (!PATH.test(term.id)) {
|
|
20
|
+
throw new TypeError(`${where}: \`id\` must be "args.<path>", "parent.<path>" or "source.<path>", got ${JSON.stringify(term.id)}`);
|
|
31
21
|
}
|
|
32
22
|
}
|
|
23
|
+
function assertDeclaredArgument(term, argumentNames, where) {
|
|
24
|
+
const [root, name] = term.id.split(".");
|
|
25
|
+
if (root !== "args" || !name || argumentNames.includes(name))
|
|
26
|
+
return;
|
|
27
|
+
const declared = argumentNames.length ? argumentNames.join(", ") : "no arguments";
|
|
28
|
+
throw new TypeError(`${where}: \`id\` reads "${term.id}", but the field declares ${declared}`);
|
|
29
|
+
}
|
|
33
30
|
function readPath(path, source, args) {
|
|
34
31
|
const [root, ...rest] = path.split(".");
|
|
35
32
|
let value = root === "args" ? args : source;
|
|
@@ -42,6 +39,78 @@ function objectIds(value) {
|
|
|
42
39
|
const values = Array.isArray(value) ? value : [value];
|
|
43
40
|
return values.filter((item) => typeof item === "string" && item.length > 0);
|
|
44
41
|
}
|
|
42
|
+
|
|
43
|
+
// src/directives/scope.ts
|
|
44
|
+
function scopeOf(node, where, options) {
|
|
45
|
+
return {
|
|
46
|
+
where,
|
|
47
|
+
argumentNames: argumentNamesOf(node),
|
|
48
|
+
namespaces: options.namespaces
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function argumentNamesOf(node) {
|
|
52
|
+
const field = node;
|
|
53
|
+
if (Array.isArray(field.args))
|
|
54
|
+
return field.args.map((arg) => arg.name);
|
|
55
|
+
if (field.args)
|
|
56
|
+
return Object.keys(field.args);
|
|
57
|
+
return field.arguments?.map((arg) => arg.name.value) ?? [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/directives/validate.ts
|
|
61
|
+
import { assertRequirement } from "@nxgt/ory-sdk";
|
|
62
|
+
function assertRequirementShape(permissions, scope) {
|
|
63
|
+
const { where, argumentNames, namespaces } = scope;
|
|
64
|
+
try {
|
|
65
|
+
assertRequirement(permissions, where);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new TypeError(error.message);
|
|
68
|
+
}
|
|
69
|
+
for (const group of permissions) {
|
|
70
|
+
for (const term of group) {
|
|
71
|
+
assertReadablePath(term, where);
|
|
72
|
+
declaredOrReported(term, argumentNames, scope);
|
|
73
|
+
if (namespaces && !namespaces.includes(term.namespace)) {
|
|
74
|
+
throw new TypeError(`${where}: unknown namespace ${JSON.stringify(term.namespace)} — known: ${namespaces.join(", ")}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function declaredOrReported(term, argumentNames, scope) {
|
|
80
|
+
if (!scope.lenient) {
|
|
81
|
+
assertDeclaredArgument(term, argumentNames, scope.where);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
assertDeclaredArgument(term, argumentNames, scope.where);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
scope.lenient(error.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/directives/check.ts
|
|
92
|
+
var CHECK_DIRECTIVE_NAME = "check";
|
|
93
|
+
function readChecks(schema, node, where, options = {}) {
|
|
94
|
+
const found = getDirective(schema, node, CHECK_DIRECTIVE_NAME) ?? [];
|
|
95
|
+
const scope = {
|
|
96
|
+
...scopeOf(node, `@check on ${where}`, options),
|
|
97
|
+
lenient: warnCheckMistake
|
|
98
|
+
};
|
|
99
|
+
return found.map((raw) => {
|
|
100
|
+
const permissions = withDefaultIds(raw.permissions);
|
|
101
|
+
assertRequirementShape(permissions, scope);
|
|
102
|
+
return {
|
|
103
|
+
permissions,
|
|
104
|
+
onDeny: raw.onDeny ?? "NOT_FOUND",
|
|
105
|
+
message: raw.message ?? undefined
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function withDefaultIds(raw) {
|
|
110
|
+
if (!Array.isArray(raw))
|
|
111
|
+
return raw;
|
|
112
|
+
return raw.map((group) => Array.isArray(group) ? group.map((term) => term && typeof term === "object" ? { ...term, id: term.id ?? DEFAULT_ID_PATH } : term) : group);
|
|
113
|
+
}
|
|
45
114
|
// src/directives/federation.ts
|
|
46
115
|
var FEDERATION_DIRECTIVES = `
|
|
47
116
|
directive @external on FIELD_DEFINITION | OBJECT
|
|
@@ -56,6 +125,204 @@ directive @composeDirective(name: String!) repeatable on SCHEMA
|
|
|
56
125
|
directive @interfaceObject on OBJECT
|
|
57
126
|
directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
|
|
58
127
|
`;
|
|
128
|
+
// src/directives/permission.ts
|
|
129
|
+
import { getDirective as getDirective2 } from "@graphql-tools/utils";
|
|
130
|
+
var PERMISSION_DIRECTIVE_NAME = "permission";
|
|
131
|
+
function readPermissions(schema, node, where, options = {}) {
|
|
132
|
+
if (!isOurs(schema))
|
|
133
|
+
return [];
|
|
134
|
+
const found = getDirective2(schema, node, PERMISSION_DIRECTIVE_NAME) ?? [];
|
|
135
|
+
const scope = scopeOf(node, `@permission on ${where}`, options);
|
|
136
|
+
return found.map((raw) => {
|
|
137
|
+
const args = permissionArgs(raw);
|
|
138
|
+
const permissions = [
|
|
139
|
+
[{ namespace: args.type, permit: args.name, id: args.id }]
|
|
140
|
+
];
|
|
141
|
+
assertRequirementShape(permissions, scope);
|
|
142
|
+
return { permissions, onDeny: args.onDeny, message: args.message };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function permissionArgs(raw) {
|
|
146
|
+
return {
|
|
147
|
+
name: raw.name,
|
|
148
|
+
type: raw.type,
|
|
149
|
+
id: raw.id ?? DEFAULT_ID_PATH,
|
|
150
|
+
onDeny: raw.onDeny ?? "NOT_FOUND",
|
|
151
|
+
message: raw.message ?? undefined
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function isOurs(schema) {
|
|
155
|
+
const declared = schema.getDirective(PERMISSION_DIRECTIVE_NAME);
|
|
156
|
+
const names = declared?.args.map((arg) => arg.name) ?? [];
|
|
157
|
+
return names.includes("name") && names.includes("type");
|
|
158
|
+
}
|
|
159
|
+
// src/directives/requirements.ts
|
|
160
|
+
function readRequirements(schema, node, where, options = {}) {
|
|
161
|
+
const queues = {
|
|
162
|
+
[CHECK_DIRECTIVE_NAME]: readChecks(schema, node, where, options),
|
|
163
|
+
[PERMISSION_DIRECTIVE_NAME]: readPermissions(schema, node, where, options)
|
|
164
|
+
};
|
|
165
|
+
const ordered = [];
|
|
166
|
+
for (const directive of directivesOf(node)) {
|
|
167
|
+
const next = queues[directive.name.value]?.shift();
|
|
168
|
+
if (next)
|
|
169
|
+
ordered.push(next);
|
|
170
|
+
}
|
|
171
|
+
return [
|
|
172
|
+
...ordered,
|
|
173
|
+
...queues[CHECK_DIRECTIVE_NAME] ?? [],
|
|
174
|
+
...queues[PERMISSION_DIRECTIVE_NAME] ?? []
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
function directivesOf(node) {
|
|
178
|
+
const field = node;
|
|
179
|
+
return field.astNode?.directives ?? field.directives ?? [];
|
|
180
|
+
}
|
|
181
|
+
// src/directives/sdl.ts
|
|
182
|
+
var CHECK_DIRECTIVE_SDL = `"""
|
|
183
|
+
One question, with the object left as a path instead of a value.
|
|
184
|
+
"""
|
|
185
|
+
input CheckPermission {
|
|
186
|
+
"""
|
|
187
|
+
The Keto namespace, e.g. "Note" — from the stack's OPL document.
|
|
188
|
+
"""
|
|
189
|
+
namespace: String!
|
|
190
|
+
|
|
191
|
+
"""
|
|
192
|
+
The permit asked of it, e.g. "view". A plain relation works too; Keto
|
|
193
|
+
answers \`false\`, not an error, for a name it does not know.
|
|
194
|
+
"""
|
|
195
|
+
permit: String!
|
|
196
|
+
|
|
197
|
+
"""
|
|
198
|
+
Where the object id is read: \`args.<path>\`, or \`parent.<path>\` /
|
|
199
|
+
\`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
|
|
200
|
+
element — that is what a mutation taking \`ids: [ID!]!\` means.
|
|
201
|
+
"""
|
|
202
|
+
id: String = "args.id"
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
"""
|
|
206
|
+
What a denial looks like from outside.
|
|
207
|
+
"""
|
|
208
|
+
enum CheckDenial {
|
|
209
|
+
"""
|
|
210
|
+
The same answer as for an id that never existed, so ids cannot be probed.
|
|
211
|
+
The default, and the one to keep unless the caller already knows the object
|
|
212
|
+
is there.
|
|
213
|
+
"""
|
|
214
|
+
NOT_FOUND
|
|
215
|
+
|
|
216
|
+
"""
|
|
217
|
+
For a second check on an object the caller can already see: a viewer asked
|
|
218
|
+
to edit already knows it exists, and "you may not change it" is honest.
|
|
219
|
+
"""
|
|
220
|
+
FORBIDDEN
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
"""
|
|
224
|
+
Deprecated: write \`@permission(name:, type:)\` in a new schema — one Keto
|
|
225
|
+
question per directive, repeated for AND, with OR kept in the Keto model. This
|
|
226
|
+
form keeps working and is evaluated in declaration order with @permission.
|
|
227
|
+
|
|
228
|
+
The permission a field requires, in disjunctive normal form: the OUTER list is
|
|
229
|
+
OR, the INNER list is AND. \`[[A, B], [C]]\` reads "(A and B) or C" — the same
|
|
230
|
+
shape \`@policy(policies: [["ADMIN"]])\` uses.
|
|
231
|
+
|
|
232
|
+
Repeatable, and evaluated in declaration order, each with its own \`onDeny\`.
|
|
233
|
+
That is how the 404-then-403 ladder is written:
|
|
234
|
+
|
|
235
|
+
updateNote(id: ID!, input: UpdateNoteInput!): Note!
|
|
236
|
+
@check(permissions: [[{ namespace: "Note", permit: "view" }]])
|
|
237
|
+
@check(permissions: [[{ namespace: "Note", permit: "edit" }]], onDeny: FORBIDDEN)
|
|
238
|
+
|
|
239
|
+
NOT for a field that answers a LIST the caller is entitled to. "Which notes may
|
|
240
|
+
I see" is not a check, it is a Keto query folded into the database filter
|
|
241
|
+
before the read. A directive there would have to fetch everything and filter
|
|
242
|
+
after, which makes \`totalCount\` and the cursors lie.
|
|
243
|
+
"""
|
|
244
|
+
directive @check(
|
|
245
|
+
permissions: [[CheckPermission!]!]!
|
|
246
|
+
onDeny: CheckDenial! = NOT_FOUND
|
|
247
|
+
|
|
248
|
+
"""
|
|
249
|
+
The i18n key the denial carries, e.g. "notes.errors.not-found". Defaults to
|
|
250
|
+
\`errors.not-found\` / \`errors.insufficient-permissions\`, the shared keys.
|
|
251
|
+
|
|
252
|
+
Set it whenever the API's own service layer answers the same refusal with a
|
|
253
|
+
domain message. Two layers guard these fields — the directive, and the
|
|
254
|
+
\`require<M>Access\` the service calls — and if they word the same 404
|
|
255
|
+
differently, the wording tells a caller WHICH one refused: a generic message
|
|
256
|
+
means "you may not", a domain one means "it is gone". That is precisely the
|
|
257
|
+
distinction NOT_FOUND exists to hide.
|
|
258
|
+
"""
|
|
259
|
+
message: String
|
|
260
|
+
) repeatable on FIELD_DEFINITION
|
|
261
|
+
`;
|
|
262
|
+
var PERMISSION_DIRECTIVE_SDL = `"""
|
|
263
|
+
What a refused @permission looks like from outside.
|
|
264
|
+
"""
|
|
265
|
+
enum PermissionDenial {
|
|
266
|
+
"""
|
|
267
|
+
The same answer as for an id that never existed, so ids cannot be probed.
|
|
268
|
+
The default.
|
|
269
|
+
"""
|
|
270
|
+
NOT_FOUND
|
|
271
|
+
|
|
272
|
+
"""
|
|
273
|
+
For a permission asked of an object the caller can already see: a viewer
|
|
274
|
+
asked to edit already knows it exists, and "you may not change it" is
|
|
275
|
+
honest.
|
|
276
|
+
"""
|
|
277
|
+
FORBIDDEN
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
"""
|
|
281
|
+
The permission a field requires: the caller must hold the permit \`name\` on the
|
|
282
|
+
\`type\` object whose id \`id\` points at.
|
|
283
|
+
|
|
284
|
+
Repeatable, and repeated ones are AND, evaluated in declaration order — the
|
|
285
|
+
404-then-403 ladder:
|
|
286
|
+
|
|
287
|
+
updateNote(id: ID!, input: UpdateNoteInput!): Note!
|
|
288
|
+
@permission(name: "view", type: "Note")
|
|
289
|
+
@permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
|
|
290
|
+
|
|
291
|
+
NOT for a field that answers a LIST the caller is entitled to: fold a Keto
|
|
292
|
+
query into the database filter before the read instead.
|
|
293
|
+
"""
|
|
294
|
+
directive @permission(
|
|
295
|
+
"""
|
|
296
|
+
The permit asked of Keto, e.g. "view". A plain relation works too; Keto
|
|
297
|
+
answers \`false\`, not an error, for a name it does not know.
|
|
298
|
+
"""
|
|
299
|
+
name: String!
|
|
300
|
+
|
|
301
|
+
"""
|
|
302
|
+
The Keto namespace, e.g. "Note" — from the stack's OPL document.
|
|
303
|
+
"""
|
|
304
|
+
type: String!
|
|
305
|
+
|
|
306
|
+
"""
|
|
307
|
+
Where the object id is read: \`args.<path>\` or \`parent.<path>\`, dotted
|
|
308
|
+
paths allowed, \`args.id\` when omitted. A value that turns out to be a LIST
|
|
309
|
+
requires the permit on every element.
|
|
310
|
+
"""
|
|
311
|
+
id: String
|
|
312
|
+
|
|
313
|
+
onDeny: PermissionDenial! = NOT_FOUND
|
|
314
|
+
|
|
315
|
+
"""
|
|
316
|
+
The i18n key the denial carries, e.g. "notes.errors.not-found". Defaults to
|
|
317
|
+
\`errors.not-found\` / \`errors.insufficient-permissions\`, the shared keys.
|
|
318
|
+
Set it when the service layer words the same refusal with a domain message,
|
|
319
|
+
so the wording does not tell a caller which layer refused.
|
|
320
|
+
"""
|
|
321
|
+
message: String
|
|
322
|
+
) repeatable on FIELD_DEFINITION
|
|
323
|
+
`;
|
|
324
|
+
var KETO_DIRECTIVES_SDL = `${CHECK_DIRECTIVE_SDL}
|
|
325
|
+
${PERMISSION_DIRECTIVE_SDL}`;
|
|
59
326
|
// src/integrations/hono.ts
|
|
60
327
|
import { Hono } from "hono";
|
|
61
328
|
import { contextStorage } from "hono/context-storage";
|
|
@@ -251,7 +518,12 @@ function createEntitiesReferenceLoader(model) {
|
|
|
251
518
|
return ids.map((id) => map.get(id) ?? null);
|
|
252
519
|
});
|
|
253
520
|
}
|
|
254
|
-
// src/utils/errors.
|
|
521
|
+
// src/utils/errors/create-error.ts
|
|
522
|
+
import { GraphQLError } from "graphql";
|
|
523
|
+
function createGraphQLError(message, options) {
|
|
524
|
+
return new GraphQLError(message, options);
|
|
525
|
+
}
|
|
526
|
+
// src/utils/errors/format-error.ts
|
|
255
527
|
import {
|
|
256
528
|
ApolloServerErrorCode,
|
|
257
529
|
unwrapResolverError
|
|
@@ -259,25 +531,49 @@ import {
|
|
|
259
531
|
import {
|
|
260
532
|
translate as translateBase
|
|
261
533
|
} from "@nxgt/i18n";
|
|
262
|
-
import {
|
|
263
|
-
import { CustomException, ErrorCode } from "@nxgt/shared-exceptions";
|
|
534
|
+
import { CustomException, ErrorCode as ErrorCode2 } from "@nxgt/shared-exceptions";
|
|
264
535
|
import { MONGO_UTILS, mongoose } from "@nxgt/shared-mongo";
|
|
265
|
-
import { GraphQLError } from "graphql";
|
|
266
536
|
import { kebabCase } from "lodash";
|
|
267
|
-
|
|
268
|
-
|
|
537
|
+
|
|
538
|
+
// src/utils/errors/ory-unavailable.ts
|
|
539
|
+
import { ErrorCode } from "@nxgt/shared-exceptions";
|
|
540
|
+
import { GraphQLError as GraphQLError2 } from "graphql";
|
|
541
|
+
function isOryUnavailable(error) {
|
|
542
|
+
if (!(error instanceof Error))
|
|
543
|
+
return false;
|
|
544
|
+
const service = error.service;
|
|
545
|
+
return error.name === "OryUnavailable" && typeof service === "string";
|
|
546
|
+
}
|
|
547
|
+
function serviceUnavailableError(error) {
|
|
548
|
+
return new GraphQLError2(`ory: ${error.service} is unavailable`, {
|
|
549
|
+
extensions: {
|
|
550
|
+
code: ErrorCode.ServiceUnavailable,
|
|
551
|
+
http: { status: 503 },
|
|
552
|
+
debugMessage: error.message
|
|
553
|
+
}
|
|
554
|
+
});
|
|
269
555
|
}
|
|
556
|
+
|
|
557
|
+
// src/utils/errors/format-error.ts
|
|
270
558
|
function createFormatError(translate = translateBase, production) {
|
|
271
559
|
return (formattedError, graphQLError) => {
|
|
272
560
|
if (production && formattedError.extensions?.stacktrace) {
|
|
273
561
|
formattedError.extensions.stacktrace = undefined;
|
|
274
562
|
}
|
|
275
563
|
const error = unwrapResolverError(graphQLError);
|
|
564
|
+
if (isOryUnavailable(error)) {
|
|
565
|
+
const unavailable = serviceUnavailableError(error);
|
|
566
|
+
return {
|
|
567
|
+
...formattedError,
|
|
568
|
+
message: unavailable.message,
|
|
569
|
+
extensions: { ...formattedError.extensions, ...unavailable.extensions }
|
|
570
|
+
};
|
|
571
|
+
}
|
|
276
572
|
if (error instanceof CustomException || error instanceof mongoose.MongooseError) {
|
|
277
573
|
const exception = error instanceof mongoose.MongooseError ? MONGO_UTILS.castError(error) : error;
|
|
278
574
|
return {
|
|
279
575
|
...formattedError,
|
|
280
|
-
message: exception.errorCode ===
|
|
576
|
+
message: exception.errorCode === ErrorCode2.ValidationError ? exception.message : translate(exception.message, exception.options),
|
|
281
577
|
extensions: {
|
|
282
578
|
...formattedError.extensions,
|
|
283
579
|
code: exception.errorCode,
|
|
@@ -306,45 +602,58 @@ function createFormatError(translate = translateBase, production) {
|
|
|
306
602
|
return formattedError;
|
|
307
603
|
};
|
|
308
604
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
};
|
|
318
|
-
function createMaskError(translate = translateBase) {
|
|
605
|
+
// src/utils/errors/mask-error.ts
|
|
606
|
+
import {
|
|
607
|
+
translate as translateBase2
|
|
608
|
+
} from "@nxgt/i18n";
|
|
609
|
+
import { CustomException as CustomException2, ErrorCode as ErrorCode3 } from "@nxgt/shared-exceptions";
|
|
610
|
+
import { MONGO_UTILS as MONGO_UTILS2, mongoose as mongoose2 } from "@nxgt/shared-mongo";
|
|
611
|
+
import { GraphQLError as GraphQLError3 } from "graphql";
|
|
612
|
+
function createMaskError(translate = translateBase2) {
|
|
319
613
|
return (error, message, isDev) => {
|
|
320
|
-
const original = error instanceof
|
|
321
|
-
if (original
|
|
322
|
-
return
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
http: { status: 503 },
|
|
326
|
-
debugMessage: original.message
|
|
327
|
-
}
|
|
328
|
-
});
|
|
614
|
+
const original = error instanceof GraphQLError3 && error.originalError ? error.originalError : error;
|
|
615
|
+
if (isOryUnavailable(original))
|
|
616
|
+
return serviceUnavailableError(original);
|
|
617
|
+
if (original instanceof CustomException2 || original instanceof mongoose2.MongooseError) {
|
|
618
|
+
return exceptionError(original, translate);
|
|
329
619
|
}
|
|
330
|
-
if (
|
|
331
|
-
const exception = original instanceof mongoose.MongooseError ? MONGO_UTILS.castError(original) : original;
|
|
332
|
-
const status = exception.code;
|
|
333
|
-
return new GraphQLError(exception.errorCode === ErrorCode.ValidationError ? exception.message : translate(exception.message, exception.options), {
|
|
334
|
-
extensions: {
|
|
335
|
-
code: exception.errorCode,
|
|
336
|
-
...status ? { http: { status } } : {},
|
|
337
|
-
...exception.debugMessage ? { debugMessage: exception.debugMessage } : {}
|
|
338
|
-
}
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
if (error instanceof GraphQLError)
|
|
620
|
+
if (isOriginalGraphQLError(error))
|
|
342
621
|
return error;
|
|
343
|
-
return new
|
|
344
|
-
|
|
622
|
+
return new GraphQLError3(message, {
|
|
623
|
+
...locationOf(error),
|
|
624
|
+
extensions: isDev ? {
|
|
625
|
+
code: ErrorCode3.InternalServerError,
|
|
626
|
+
debugMessage: String(original)
|
|
627
|
+
} : { code: ErrorCode3.InternalServerError }
|
|
345
628
|
});
|
|
346
629
|
};
|
|
347
630
|
}
|
|
631
|
+
function exceptionError(original, translate) {
|
|
632
|
+
const exception = original instanceof mongoose2.MongooseError ? MONGO_UTILS2.castError(original) : original;
|
|
633
|
+
const status = exception.code;
|
|
634
|
+
return new GraphQLError3(exception.errorCode === ErrorCode3.ValidationError ? exception.message : translate(exception.message, exception.options), {
|
|
635
|
+
extensions: {
|
|
636
|
+
code: exception.errorCode,
|
|
637
|
+
...status ? { http: { status } } : {},
|
|
638
|
+
...exception.debugMessage ? { debugMessage: exception.debugMessage } : {}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
function isOriginalGraphQLError(error) {
|
|
643
|
+
if (!(error instanceof GraphQLError3))
|
|
644
|
+
return false;
|
|
645
|
+
return error.originalError == null ? true : isOriginalGraphQLError(error.originalError);
|
|
646
|
+
}
|
|
647
|
+
function locationOf(error) {
|
|
648
|
+
if (!(error instanceof GraphQLError3))
|
|
649
|
+
return {};
|
|
650
|
+
return {
|
|
651
|
+
nodes: error.nodes,
|
|
652
|
+
source: error.source,
|
|
653
|
+
positions: error.positions,
|
|
654
|
+
path: error.path
|
|
655
|
+
};
|
|
656
|
+
}
|
|
348
657
|
// src/utils/publish.ts
|
|
349
658
|
import { createRedisEventTarget } from "@graphql-yoga/redis-event-target";
|
|
350
659
|
import { createPubSub } from "graphql-yoga";
|
|
@@ -506,6 +815,74 @@ function createYogaHono(yoga, options) {
|
|
|
506
815
|
app.use(yoga.graphqlEndpoint, honoYoga(yoga));
|
|
507
816
|
return app;
|
|
508
817
|
}
|
|
818
|
+
// src/plugins/apply-keto-checks.ts
|
|
819
|
+
import { getDirective as getDirective3, MapperKind, mapSchema } from "@graphql-tools/utils";
|
|
820
|
+
import { evaluateRequirement } from "@nxgt/ory-sdk";
|
|
821
|
+
import { CustomException as CustomException3 } from "@nxgt/shared-exceptions";
|
|
822
|
+
import {
|
|
823
|
+
defaultFieldResolver
|
|
824
|
+
} from "graphql";
|
|
825
|
+
function applyKetoChecks(schema, options = {}) {
|
|
826
|
+
return mapSchema(schema, {
|
|
827
|
+
[MapperKind.INTERFACE_FIELD]: (fieldConfig, fieldName, typeName) => {
|
|
828
|
+
refuseOnInterface(schema, fieldConfig, `${typeName}.${fieldName}`);
|
|
829
|
+
return fieldConfig;
|
|
830
|
+
},
|
|
831
|
+
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
|
|
832
|
+
const where = `${typeName}.${fieldName}`;
|
|
833
|
+
const requirements = readRequirements(schema, fieldConfig, where, options);
|
|
834
|
+
if (requirements.length === 0)
|
|
835
|
+
return fieldConfig;
|
|
836
|
+
return guarded(fieldConfig, requirements, where);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
function refuseOnInterface(schema, fieldConfig, where) {
|
|
841
|
+
const message = `${where}: @check / @permission on an interface field guards nothing — no resolver runs there. Put it on each implementing type's field`;
|
|
842
|
+
if (readPermissions(schema, fieldConfig, where).length > 0) {
|
|
843
|
+
throw new TypeError(message);
|
|
844
|
+
}
|
|
845
|
+
if (getDirective3(schema, fieldConfig, CHECK_DIRECTIVE_NAME)?.length) {
|
|
846
|
+
warnCheckMistake(message);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function guarded(fieldConfig, requirements, where) {
|
|
850
|
+
const resolve = fieldConfig.resolve ?? defaultFieldResolver;
|
|
851
|
+
return {
|
|
852
|
+
...fieldConfig,
|
|
853
|
+
resolve: async (source, args, context, info) => {
|
|
854
|
+
const ctx = context;
|
|
855
|
+
const subject = ctx.ory?.subject;
|
|
856
|
+
if (!subject) {
|
|
857
|
+
throw CustomException3.unauthenticated({
|
|
858
|
+
message: "errors.unauthenticated"
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
const check = ctx.ketoChecks;
|
|
862
|
+
if (!check) {
|
|
863
|
+
throw new Error(`${where}: no checker on the context — useKetoChecks(ory) is not registered`);
|
|
864
|
+
}
|
|
865
|
+
for (const { permissions, onDeny, message } of requirements) {
|
|
866
|
+
const allowed = await evaluateRequirement(permissions, (term) => objectsOf(term, source, args, where), check, subject);
|
|
867
|
+
if (!allowed)
|
|
868
|
+
throw denial(onDeny, message);
|
|
869
|
+
}
|
|
870
|
+
return resolve(source, args, context, info);
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
function objectsOf(term, source, args, where) {
|
|
875
|
+
const ids = objectIds(readPath(term.id, source, args));
|
|
876
|
+
if (ids.length === 0) {
|
|
877
|
+
throw new Error(`${where}: "${term.id}" resolved no object id`);
|
|
878
|
+
}
|
|
879
|
+
return ids;
|
|
880
|
+
}
|
|
881
|
+
function denial(onDeny, message) {
|
|
882
|
+
return onDeny === "FORBIDDEN" ? CustomException3.forbidden({
|
|
883
|
+
message: message ?? "errors.insufficient-permissions"
|
|
884
|
+
}) : CustomException3.notFound({ message: message ?? "errors.not-found" });
|
|
885
|
+
}
|
|
509
886
|
// src/plugins/auth.ts
|
|
510
887
|
function useAuth() {
|
|
511
888
|
return {
|
|
@@ -518,82 +895,32 @@ function useAuth() {
|
|
|
518
895
|
};
|
|
519
896
|
}
|
|
520
897
|
// src/plugins/extract-jwt.ts
|
|
521
|
-
import { logger } from "@nxgt/shared-logging";
|
|
898
|
+
import { logger as logger2 } from "@nxgt/shared-logging";
|
|
522
899
|
var extractJwtPlugin = {
|
|
523
900
|
async requestDidStart({ request, contextValue }) {
|
|
524
|
-
|
|
901
|
+
logger2.info(`[${request.extensions?.subgraphName}] Extracting JWT from request extensions: ${request.extensions?.payload?.sub}`, request.extensions);
|
|
525
902
|
contextValue.jwt = {
|
|
526
903
|
payload: request.extensions?.payload
|
|
527
904
|
};
|
|
528
905
|
}
|
|
529
906
|
};
|
|
530
|
-
// src/plugins/keto-
|
|
531
|
-
import {
|
|
532
|
-
import {
|
|
533
|
-
evaluateRequirement,
|
|
534
|
-
tuple
|
|
535
|
-
} from "@nxgt/ory-sdk";
|
|
536
|
-
import { CustomException as CustomException2 } from "@nxgt/shared-exceptions";
|
|
907
|
+
// src/plugins/keto-checker.ts
|
|
908
|
+
import { tuple } from "@nxgt/ory-sdk";
|
|
537
909
|
import DataLoader2 from "dataloader";
|
|
538
|
-
import { defaultFieldResolver } from "graphql";
|
|
539
910
|
function createKetoChecks(ory) {
|
|
540
911
|
const loader = new DataLoader2((questions) => ory.checkMany([...questions]), {
|
|
541
912
|
cacheKeyFn: ({ permission, subject }) => tuple(permission, subject)
|
|
542
913
|
});
|
|
543
914
|
return (permission, subject) => loader.load({ permission, subject });
|
|
544
915
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
|
|
548
|
-
const where = `${typeName}.${fieldName}`;
|
|
549
|
-
const checks = readChecks(schema, fieldConfig, where);
|
|
550
|
-
if (checks.length === 0)
|
|
551
|
-
return fieldConfig;
|
|
552
|
-
const resolve = fieldConfig.resolve ?? defaultFieldResolver;
|
|
553
|
-
return {
|
|
554
|
-
...fieldConfig,
|
|
555
|
-
resolve: async (source, args, context, info) => {
|
|
556
|
-
const ctx = context;
|
|
557
|
-
const subject = ctx.ory?.subject;
|
|
558
|
-
if (!subject) {
|
|
559
|
-
throw CustomException2.unauthenticated({
|
|
560
|
-
message: "errors.unauthenticated"
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
const check = ctx.ketoChecks;
|
|
564
|
-
if (!check) {
|
|
565
|
-
throw new Error(`@check on ${where}: no checker on the context — useKetoChecks(ory) is not registered`);
|
|
566
|
-
}
|
|
567
|
-
for (const { permissions, onDeny, message } of checks) {
|
|
568
|
-
const allowed = await evaluateRequirement(permissions, (term) => objectsOf(term, source, args, where), check, subject);
|
|
569
|
-
if (!allowed)
|
|
570
|
-
throw denial(onDeny, message);
|
|
571
|
-
}
|
|
572
|
-
return resolve(source, args, context, info);
|
|
573
|
-
}
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
function objectsOf(term, source, args, where) {
|
|
579
|
-
const ids = objectIds(readPath(term.id, source, args));
|
|
580
|
-
if (ids.length === 0) {
|
|
581
|
-
throw new Error(`@check on ${where}: "${term.id}" resolved no object id`);
|
|
582
|
-
}
|
|
583
|
-
return ids;
|
|
584
|
-
}
|
|
585
|
-
function denial(onDeny, message) {
|
|
586
|
-
return onDeny === "FORBIDDEN" ? CustomException2.forbidden({
|
|
587
|
-
message: message ?? "errors.insufficient-permissions"
|
|
588
|
-
}) : CustomException2.notFound({ message: message ?? "errors.not-found" });
|
|
589
|
-
}
|
|
590
|
-
function useKetoChecks(ory) {
|
|
916
|
+
// src/plugins/keto-checks.ts
|
|
917
|
+
function useKetoChecks(ory, options = {}) {
|
|
591
918
|
const transformed = new WeakSet;
|
|
592
919
|
return {
|
|
593
920
|
onSchemaChange: ({ schema, replaceSchema }) => {
|
|
594
921
|
if (transformed.has(schema))
|
|
595
922
|
return;
|
|
596
|
-
const next = applyKetoChecks(schema);
|
|
923
|
+
const next = applyKetoChecks(schema, options);
|
|
597
924
|
transformed.add(next);
|
|
598
925
|
replaceSchema(next);
|
|
599
926
|
},
|
|
@@ -602,13 +929,38 @@ function useKetoChecks(ory) {
|
|
|
602
929
|
}
|
|
603
930
|
};
|
|
604
931
|
}
|
|
932
|
+
// src/plugins/keto-helpers.ts
|
|
933
|
+
import { CustomException as CustomException4 } from "@nxgt/shared-exceptions";
|
|
934
|
+
function requireUser(context) {
|
|
935
|
+
if (!context.user) {
|
|
936
|
+
throw CustomException4.unauthenticated({
|
|
937
|
+
message: "errors.unauthenticated"
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return context.user;
|
|
941
|
+
}
|
|
942
|
+
async function can(context, question) {
|
|
943
|
+
const subject = context.ory?.subject;
|
|
944
|
+
if (!subject) {
|
|
945
|
+
throw CustomException4.unauthenticated({
|
|
946
|
+
message: "errors.unauthenticated"
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
if (!context.ketoChecks) {
|
|
950
|
+
throw new Error("can(): no checker on the context — useKetoChecks(ory) is not registered");
|
|
951
|
+
}
|
|
952
|
+
const permission = {
|
|
953
|
+
namespace: question.type,
|
|
954
|
+
object: question.id,
|
|
955
|
+
relation: question.name
|
|
956
|
+
};
|
|
957
|
+
return context.ketoChecks(permission, subject);
|
|
958
|
+
}
|
|
605
959
|
// src/plugins/ory-auth.ts
|
|
606
960
|
import {
|
|
607
|
-
bearerOf
|
|
608
|
-
OryUnavailable as OryUnavailable2
|
|
961
|
+
bearerOf
|
|
609
962
|
} from "@nxgt/ory-sdk";
|
|
610
963
|
import { claimsFromOryPrincipal } from "@nxgt/security/integrations/ory";
|
|
611
|
-
import { GraphQLError as GraphQLError2 } from "graphql";
|
|
612
964
|
function toPrincipal(ory) {
|
|
613
965
|
const email = ory.identity?.email;
|
|
614
966
|
return {
|
|
@@ -625,20 +977,14 @@ function toPrincipal(ory) {
|
|
|
625
977
|
};
|
|
626
978
|
}
|
|
627
979
|
function oryUnavailableError(error) {
|
|
628
|
-
return
|
|
629
|
-
extensions: {
|
|
630
|
-
code: "SERVICE_UNAVAILABLE",
|
|
631
|
-
http: { status: 503 },
|
|
632
|
-
debugMessage: error.message
|
|
633
|
-
}
|
|
634
|
-
});
|
|
980
|
+
return serviceUnavailableError(error);
|
|
635
981
|
}
|
|
636
982
|
async function resolveOryPrincipal(ory, headers) {
|
|
637
983
|
let principal;
|
|
638
984
|
try {
|
|
639
985
|
principal = await ory.resolve(headers);
|
|
640
986
|
} catch (error) {
|
|
641
|
-
if (error
|
|
987
|
+
if (isOryUnavailable(error))
|
|
642
988
|
throw oryUnavailableError(error);
|
|
643
989
|
throw error;
|
|
644
990
|
}
|
|
@@ -747,15 +1093,15 @@ import {
|
|
|
747
1093
|
} from "graphql-scalars";
|
|
748
1094
|
|
|
749
1095
|
// src/scalars/custom/utils.ts
|
|
750
|
-
import { CustomException as
|
|
751
|
-
import { logger as
|
|
1096
|
+
import { CustomException as CustomException5 } from "@nxgt/shared-exceptions";
|
|
1097
|
+
import { logger as logger3 } from "@nxgt/shared-logging";
|
|
752
1098
|
import { GraphQLScalarType as GraphQLScalarType2 } from "graphql";
|
|
753
1099
|
function createScalarFrom(scalar, {
|
|
754
1100
|
name,
|
|
755
1101
|
description,
|
|
756
1102
|
errorMessage
|
|
757
1103
|
}) {
|
|
758
|
-
const exception =
|
|
1104
|
+
const exception = CustomException5.badRequest({
|
|
759
1105
|
message: errorMessage
|
|
760
1106
|
});
|
|
761
1107
|
return new GraphQLScalarType2({
|
|
@@ -769,7 +1115,7 @@ function createScalarFrom(scalar, {
|
|
|
769
1115
|
try {
|
|
770
1116
|
return scalar.serialize(value);
|
|
771
1117
|
} catch (e) {
|
|
772
|
-
|
|
1118
|
+
logger3.error(e);
|
|
773
1119
|
throw exception;
|
|
774
1120
|
}
|
|
775
1121
|
},
|
|
@@ -780,18 +1126,18 @@ function createScalarFrom(scalar, {
|
|
|
780
1126
|
try {
|
|
781
1127
|
return scalar.parseValue(value);
|
|
782
1128
|
} catch (e) {
|
|
783
|
-
|
|
1129
|
+
logger3.error(e);
|
|
784
1130
|
throw exception;
|
|
785
1131
|
}
|
|
786
1132
|
},
|
|
787
|
-
parseLiteral(ast) {
|
|
1133
|
+
parseLiteral(ast, variables) {
|
|
788
1134
|
if (!errorMessage) {
|
|
789
|
-
return scalar.parseLiteral(ast);
|
|
1135
|
+
return scalar.parseLiteral(ast, variables);
|
|
790
1136
|
}
|
|
791
1137
|
try {
|
|
792
|
-
return scalar.parseLiteral(ast);
|
|
1138
|
+
return scalar.parseLiteral(ast, variables);
|
|
793
1139
|
} catch (e) {
|
|
794
|
-
|
|
1140
|
+
logger3.error(e);
|
|
795
1141
|
throw exception;
|
|
796
1142
|
}
|
|
797
1143
|
}
|
|
@@ -995,8 +1341,12 @@ directive @link(url: String!, import: [String!]) on SCHEMA
|
|
|
995
1341
|
import graphqlUploadExpress from "graphql-upload/graphqlUploadExpress.mjs";
|
|
996
1342
|
export {
|
|
997
1343
|
CHECK_DIRECTIVE_NAME,
|
|
1344
|
+
CHECK_DIRECTIVE_SDL,
|
|
998
1345
|
DataLoader3 as DataLoader,
|
|
999
1346
|
FEDERATION_DIRECTIVES,
|
|
1347
|
+
KETO_DIRECTIVES_SDL,
|
|
1348
|
+
PERMISSION_DIRECTIVE_NAME,
|
|
1349
|
+
PERMISSION_DIRECTIVE_SDL,
|
|
1000
1350
|
SCALARS_MAPPING,
|
|
1001
1351
|
SCALAR_RESOLVERS,
|
|
1002
1352
|
SHARED_SCHEMA_PATH,
|
|
@@ -1004,6 +1354,7 @@ export {
|
|
|
1004
1354
|
applyKetoChecks,
|
|
1005
1355
|
assertReadablePath,
|
|
1006
1356
|
buildSubgraphSchema,
|
|
1357
|
+
can,
|
|
1007
1358
|
createChangeResolvers,
|
|
1008
1359
|
createChildrenConnectionDataLoader,
|
|
1009
1360
|
createChildrenListDataLoader,
|
|
@@ -1022,15 +1373,20 @@ export {
|
|
|
1022
1373
|
generateSchema,
|
|
1023
1374
|
graphqlUploadExpress,
|
|
1024
1375
|
honoYoga,
|
|
1376
|
+
isOryUnavailable,
|
|
1025
1377
|
loadTypeDefs,
|
|
1026
1378
|
objectIds,
|
|
1027
1379
|
oryUnavailableError,
|
|
1028
1380
|
readChecks,
|
|
1029
1381
|
readPath,
|
|
1382
|
+
readPermissions,
|
|
1383
|
+
readRequirements,
|
|
1030
1384
|
renderSandbox,
|
|
1385
|
+
requireUser,
|
|
1031
1386
|
resolveOryPrincipal,
|
|
1032
1387
|
resolveWsUser,
|
|
1033
1388
|
sandboxExpolorer,
|
|
1389
|
+
serviceUnavailableError,
|
|
1034
1390
|
setupYogaWebSocketServer,
|
|
1035
1391
|
toPrincipal,
|
|
1036
1392
|
useAuth,
|
|
@@ -1038,5 +1394,5 @@ export {
|
|
|
1038
1394
|
useOryAuth
|
|
1039
1395
|
};
|
|
1040
1396
|
|
|
1041
|
-
//# debugId=
|
|
1397
|
+
//# debugId=E72214B01404888264756E2164756E21
|
|
1042
1398
|
//# sourceMappingURL=index.js.map
|