@appxdigital/appx-core 0.1.128-beta.3 → 0.1.128-beta.5
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/CHANGELOG.md +2 -0
- package/dist/common/decorators/field-requires.decorator.d.ts +50 -0
- package/dist/common/decorators/field-requires.decorator.d.ts.map +1 -0
- package/dist/common/decorators/field-requires.decorator.js +54 -0
- package/dist/graphql/field-requires.registry.d.ts +27 -0
- package/dist/graphql/field-requires.registry.d.ts.map +1 -0
- package/dist/graphql/field-requires.registry.js +96 -0
- package/dist/graphql/generic.resolver.d.ts.map +1 -1
- package/dist/graphql/generic.resolver.js +99 -2
- package/dist/graphql/graphql.module.d.ts.map +1 -1
- package/dist/graphql/graphql.module.js +14 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/prisma/prisma.service.d.ts +15 -0
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +17 -0
- package/docs/graphql.md +62 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -29,6 +29,8 @@ Read-only GraphQL, reworked into a **namespaced, opt-in-per-module** API.
|
|
|
29
29
|
- **Nested selection is to-one only.** Generated GraphQL model types expose scalars, **to-one** relations, and `_count`; **to-many relations are not nested-selectable** (nested lists have no pagination, so they could return unbounded rows). Fetch a list at the top level with pagination — `track { find(where: { albumId … }, take, skip) }`. Filtering by a to-many relation in `where` (`some`/`every`/`none`) and `_count { … }` still work.
|
|
30
30
|
- **Self-documenting operations.** `find` / `get` / `count` and their `where` arg carry GraphQL descriptions (visible in the Apollo Sandbox); model-field descriptions come from Prisma `///` doc comments as before. See [docs/graphql.md](./docs/graphql.md) for the full `where`/`orderBy`/pagination reference.
|
|
31
31
|
- **Malformed filter values return `400`, not `500`.** A `where`/`orderBy` value Prisma can't parse (e.g. `createdAt: { gt: 2024 }` — a number where a date is expected) now surfaces a clean `400`, instead of an internal error that leaked the raw Prisma invocation.
|
|
32
|
+
- **Output shaping via native field resolvers.** Transform a field on the way out (sign a stored key into a URL, mask, format) or add a computed field with a `@Resolver(() => Model)` + `@ResolveField`, registered in the module. A field resolver reads its source columns from `@Parent()`; ABAC field omission still runs at query time, so a transform never sees a `@Role`-restricted column. New **`@FieldRequires('col')`** (one column, several, or an array) declares which columns a field reads so the generated `find`/`get` fetches exactly those — for a computed field, an in-place override, **and a field on a nested relation** (the source columns are resolved from a bootstrap registry, so declarations apply at every level). Undeclared field resolvers fall back to fetching the model's scalar columns; relations always stay selection-driven. See [docs/graphql.md](./docs/graphql.md) → *Shaping read output*.
|
|
33
|
+
- **GraphQL introspection on in development.** The Apollo Sandbox (`GET /graphql`) needs introspection, which Apollo Server v4 disables under `NODE_ENV=production` (`INTROSPECTION_DISABLED`). It is now enabled in development and disabled in production by default (defense-in-depth — introspection exposes schema shape, not data, which ABAC still governs). Opt in for a production deployment with `APPX_GRAPHQL_INTROSPECTION=true`.
|
|
32
34
|
|
|
33
35
|
### Fixed
|
|
34
36
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reflect metadata key under which {@link FieldRequires} records a field
|
|
3
|
+
* resolver's source columns. Read at bootstrap by the GraphQL field-requires
|
|
4
|
+
* scanner (see `field-requires.registry.ts`) to build the model→field→columns
|
|
5
|
+
* registry the read resolver consults.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FIELD_REQUIRES_METADATA = "appx:field_requires";
|
|
8
|
+
/**
|
|
9
|
+
* Declare the model columns a custom GraphQL `@ResolveField` reads from its
|
|
10
|
+
* `@Parent()`, so the generated `find` / `get` fetches those columns even when
|
|
11
|
+
* the client didn't select them — at the top level and on nested relations.
|
|
12
|
+
*
|
|
13
|
+
* Use it on a computed field or an in-place transform whose value derives from a
|
|
14
|
+
* column, e.g. a signed `coverUrl` built from a stored `coverKey`. Without it,
|
|
15
|
+
* only the columns the client selected are fetched and `@Parent().coverKey` would
|
|
16
|
+
* be `undefined`.
|
|
17
|
+
*
|
|
18
|
+
* - Declared → only the listed columns are fetched for the field.
|
|
19
|
+
* - Not declared → all of the model's scalar columns are fetched.
|
|
20
|
+
*
|
|
21
|
+
* ABAC applies: a column the caller's role may not read is omitted at query time,
|
|
22
|
+
* so the resolver sees it as absent — a transform can never surface a
|
|
23
|
+
* `@Role`-restricted value.
|
|
24
|
+
*
|
|
25
|
+
* Accepts a single column, several columns, or an array.
|
|
26
|
+
*
|
|
27
|
+
* @param columns - The scalar column name(s) the resolver reads from its parent.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* @Resolver(() => Project)
|
|
32
|
+
* export class ProjectFields {
|
|
33
|
+
* constructor(private readonly urls: UrlSigner) {}
|
|
34
|
+
*
|
|
35
|
+
* @ResolveField(() => String, { nullable: true })
|
|
36
|
+
* @FieldRequires('coverKey')
|
|
37
|
+
* coverUrl(@Parent() p: Project) {
|
|
38
|
+
* return p.coverKey ? this.urls.sign(p.coverKey) : null;
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* @ResolveField(() => String, { nullable: true })
|
|
42
|
+
* @FieldRequires(['firstName', 'lastName'])
|
|
43
|
+
* fullName(@Parent() p: Project) {
|
|
44
|
+
* return [p.firstName, p.lastName].filter(Boolean).join(' ') || null;
|
|
45
|
+
* }
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare const FieldRequires: (...columns: (string | string[])[]) => import("@nestjs/common").CustomDecorator<string>;
|
|
50
|
+
//# sourceMappingURL=field-requires.decorator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"field-requires.decorator.d.ts","sourceRoot":"","sources":["../../../src/common/decorators/field-requires.decorator.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,wBAAwB,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,aAAa,eAAgB,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,qDACP,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FieldRequires = exports.FIELD_REQUIRES_METADATA = void 0;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
5
|
+
/**
|
|
6
|
+
* Reflect metadata key under which {@link FieldRequires} records a field
|
|
7
|
+
* resolver's source columns. Read at bootstrap by the GraphQL field-requires
|
|
8
|
+
* scanner (see `field-requires.registry.ts`) to build the model→field→columns
|
|
9
|
+
* registry the read resolver consults.
|
|
10
|
+
*/
|
|
11
|
+
exports.FIELD_REQUIRES_METADATA = 'appx:field_requires';
|
|
12
|
+
/**
|
|
13
|
+
* Declare the model columns a custom GraphQL `@ResolveField` reads from its
|
|
14
|
+
* `@Parent()`, so the generated `find` / `get` fetches those columns even when
|
|
15
|
+
* the client didn't select them — at the top level and on nested relations.
|
|
16
|
+
*
|
|
17
|
+
* Use it on a computed field or an in-place transform whose value derives from a
|
|
18
|
+
* column, e.g. a signed `coverUrl` built from a stored `coverKey`. Without it,
|
|
19
|
+
* only the columns the client selected are fetched and `@Parent().coverKey` would
|
|
20
|
+
* be `undefined`.
|
|
21
|
+
*
|
|
22
|
+
* - Declared → only the listed columns are fetched for the field.
|
|
23
|
+
* - Not declared → all of the model's scalar columns are fetched.
|
|
24
|
+
*
|
|
25
|
+
* ABAC applies: a column the caller's role may not read is omitted at query time,
|
|
26
|
+
* so the resolver sees it as absent — a transform can never surface a
|
|
27
|
+
* `@Role`-restricted value.
|
|
28
|
+
*
|
|
29
|
+
* Accepts a single column, several columns, or an array.
|
|
30
|
+
*
|
|
31
|
+
* @param columns - The scalar column name(s) the resolver reads from its parent.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* @Resolver(() => Project)
|
|
36
|
+
* export class ProjectFields {
|
|
37
|
+
* constructor(private readonly urls: UrlSigner) {}
|
|
38
|
+
*
|
|
39
|
+
* @ResolveField(() => String, { nullable: true })
|
|
40
|
+
* @FieldRequires('coverKey')
|
|
41
|
+
* coverUrl(@Parent() p: Project) {
|
|
42
|
+
* return p.coverKey ? this.urls.sign(p.coverKey) : null;
|
|
43
|
+
* }
|
|
44
|
+
*
|
|
45
|
+
* @ResolveField(() => String, { nullable: true })
|
|
46
|
+
* @FieldRequires(['firstName', 'lastName'])
|
|
47
|
+
* fullName(@Parent() p: Project) {
|
|
48
|
+
* return [p.firstName, p.lastName].filter(Boolean).join(' ') || null;
|
|
49
|
+
* }
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
const FieldRequires = (...columns) => (0, common_1.SetMetadata)(exports.FIELD_REQUIRES_METADATA, columns.flat());
|
|
54
|
+
exports.FieldRequires = FieldRequires;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
|
|
3
|
+
/**
|
|
4
|
+
* Look up a field resolver on a model. Returns `undefined` if the field is not a
|
|
5
|
+
* custom `@ResolveField` (i.e. a plain column), otherwise `{ requires }` where
|
|
6
|
+
* `requires` is the declared columns or `null` if none were declared.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getFieldRequires(model: string, field: string): {
|
|
9
|
+
requires: string[] | null;
|
|
10
|
+
} | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Scans every resolver provider once at startup and records each `@ResolveField`
|
|
13
|
+
* as `Model.field` → its `@FieldRequires` columns. Runs after all providers are
|
|
14
|
+
* instantiated, so field resolvers registered in any feature module are included.
|
|
15
|
+
*
|
|
16
|
+
* This scan (not a per-request read) is required because the code that builds the
|
|
17
|
+
* Prisma query — the generated `find`/`get` — must know the source columns of
|
|
18
|
+
* field resolvers on OTHER classes and nested relation models, not just the
|
|
19
|
+
* executing handler.
|
|
20
|
+
*/
|
|
21
|
+
export declare class FieldRequiresScanner implements OnModuleInit {
|
|
22
|
+
private readonly discovery;
|
|
23
|
+
private readonly scanner;
|
|
24
|
+
constructor(discovery: DiscoveryService, scanner: MetadataScanner);
|
|
25
|
+
onModuleInit(): void;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=field-requires.registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"field-requires.registry.d.ts","sourceRoot":"","sources":["../../src/graphql/field-requires.registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,YAAY,EAAC,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAC,gBAAgB,EAAE,eAAe,EAAC,MAAM,cAAc,CAAC;AAqB/D;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG;IAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAAC,GAAG,SAAS,CAMtG;AAED;;;;;;;;;GASG;AACH,qBACa,oBAAqB,YAAW,YAAY;IAEjD,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADP,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE,eAAe;IAG7C,YAAY,IAAI,IAAI;CA+BvB"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.FieldRequiresScanner = exports.getFieldRequires = void 0;
|
|
13
|
+
const common_1 = require("@nestjs/common");
|
|
14
|
+
const core_1 = require("@nestjs/core");
|
|
15
|
+
const field_requires_decorator_1 = require("../common/decorators/field-requires.decorator");
|
|
16
|
+
// NestJS GraphQL reflect keys (set by @Resolver / @ResolveField). Reading them is
|
|
17
|
+
// how we recover, for each field resolver, the model it belongs to and the field
|
|
18
|
+
// it resolves — including in-place overrides, whose declared columns never reach
|
|
19
|
+
// the built schema field.
|
|
20
|
+
const RESOLVER_NAME_METADATA = 'graphql:resolver_name';
|
|
21
|
+
const RESOLVER_PROPERTY_METADATA = 'graphql:resolve_property';
|
|
22
|
+
/**
|
|
23
|
+
* `Model.field` → the columns the field resolver reads, or `null` when the field
|
|
24
|
+
* is a custom resolver that declared nothing (an undeclared resolver means "fetch
|
|
25
|
+
* this model's scalars, we can't know what it reads"). Absence of a key means the
|
|
26
|
+
* field is not a custom resolver at all (a plain column).
|
|
27
|
+
*
|
|
28
|
+
* Populated once at bootstrap by {@link FieldRequiresScanner}; read by the
|
|
29
|
+
* generated read resolver when building the Prisma `select`.
|
|
30
|
+
*/
|
|
31
|
+
const registry = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* Look up a field resolver on a model. Returns `undefined` if the field is not a
|
|
34
|
+
* custom `@ResolveField` (i.e. a plain column), otherwise `{ requires }` where
|
|
35
|
+
* `requires` is the declared columns or `null` if none were declared.
|
|
36
|
+
*/
|
|
37
|
+
function getFieldRequires(model, field) {
|
|
38
|
+
const key = `${model}.${field}`;
|
|
39
|
+
if (!registry.has(key)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return { requires: registry.get(key) ?? null };
|
|
43
|
+
}
|
|
44
|
+
exports.getFieldRequires = getFieldRequires;
|
|
45
|
+
/**
|
|
46
|
+
* Scans every resolver provider once at startup and records each `@ResolveField`
|
|
47
|
+
* as `Model.field` → its `@FieldRequires` columns. Runs after all providers are
|
|
48
|
+
* instantiated, so field resolvers registered in any feature module are included.
|
|
49
|
+
*
|
|
50
|
+
* This scan (not a per-request read) is required because the code that builds the
|
|
51
|
+
* Prisma query — the generated `find`/`get` — must know the source columns of
|
|
52
|
+
* field resolvers on OTHER classes and nested relation models, not just the
|
|
53
|
+
* executing handler.
|
|
54
|
+
*/
|
|
55
|
+
let FieldRequiresScanner = exports.FieldRequiresScanner = class FieldRequiresScanner {
|
|
56
|
+
constructor(discovery, scanner) {
|
|
57
|
+
this.discovery = discovery;
|
|
58
|
+
this.scanner = scanner;
|
|
59
|
+
}
|
|
60
|
+
onModuleInit() {
|
|
61
|
+
registry.clear();
|
|
62
|
+
for (const wrapper of this.discovery.getProviders()) {
|
|
63
|
+
const instance = wrapper.instance;
|
|
64
|
+
if (!instance || typeof instance !== 'object') {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// The model a resolver is bound to: @Resolver(() => Model) records the
|
|
68
|
+
// model name on the class under RESOLVER_NAME_METADATA.
|
|
69
|
+
const model = Reflect.getMetadata(RESOLVER_NAME_METADATA, instance.constructor);
|
|
70
|
+
if (!model || typeof model !== 'string') {
|
|
71
|
+
continue; // not a model-bound resolver (root resolvers, other providers)
|
|
72
|
+
}
|
|
73
|
+
const prototype = Object.getPrototypeOf(instance);
|
|
74
|
+
if (!prototype) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const methodName of this.scanner.getAllMethodNames(prototype)) {
|
|
78
|
+
const handler = prototype[methodName];
|
|
79
|
+
if (typeof handler !== 'function') {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (Reflect.getMetadata(RESOLVER_PROPERTY_METADATA, handler) !== true) {
|
|
83
|
+
continue; // not a @ResolveField
|
|
84
|
+
}
|
|
85
|
+
const field = Reflect.getMetadata(RESOLVER_NAME_METADATA, handler) || methodName;
|
|
86
|
+
const requires = Reflect.getMetadata(field_requires_decorator_1.FIELD_REQUIRES_METADATA, handler);
|
|
87
|
+
registry.set(`${model}.${field}`, Array.isArray(requires) ? requires : null);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
exports.FieldRequiresScanner = FieldRequiresScanner = __decorate([
|
|
93
|
+
(0, common_1.Injectable)(),
|
|
94
|
+
__metadata("design:paramtypes", [core_1.DiscoveryService,
|
|
95
|
+
core_1.MetadataScanner])
|
|
96
|
+
], FieldRequiresScanner);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generic.resolver.d.ts","sourceRoot":"","sources":["../../src/graphql/generic.resolver.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,IAAI,EAAC,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"generic.resolver.d.ts","sourceRoot":"","sources":["../../src/graphql/generic.resolver.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,IAAI,EAAC,MAAM,iBAAiB,CAAC;AAoIrC;;;;GAIG;AACH,MAAM,WAAW,kBAAkB,CAAC,SAAS,GAAG,GAAG;IAC/C,iFAAiF;IACjF,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;IACvB,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,YAAY,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACxB,oDAAoD;IACpD,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACzB,8DAA8D;IAC9D,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,kBAAkB,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAuE/F"}
|
|
@@ -16,7 +16,9 @@ exports.CoreGraphqlResolver = void 0;
|
|
|
16
16
|
const graphql_1 = require("@nestjs/graphql");
|
|
17
17
|
const common_1 = require("@nestjs/common");
|
|
18
18
|
const plugins_1 = require("@paljs/plugins");
|
|
19
|
+
const graphql_2 = require("graphql");
|
|
19
20
|
const prisma_service_1 = require("../prisma/prisma.service");
|
|
21
|
+
const field_requires_registry_1 = require("./field-requires.registry");
|
|
20
22
|
/**
|
|
21
23
|
* Run a read and translate a Prisma **validation** error into a `400`. A
|
|
22
24
|
* malformed `where`/`orderBy` value (e.g. `createdAt: { gt: 2024 }` — a number
|
|
@@ -36,6 +38,101 @@ async function readOrBadRequest(op) {
|
|
|
36
38
|
throw e;
|
|
37
39
|
}
|
|
38
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the Prisma `select` for a read so that every custom `@ResolveField` — at
|
|
43
|
+
* the top level and on nested relations — receives its source columns in
|
|
44
|
+
* `@Parent()`.
|
|
45
|
+
*
|
|
46
|
+
* `PrismaSelect` narrows each query to the columns the GraphQL request selected,
|
|
47
|
+
* which breaks the standard NestJS contract that a `@ResolveField` receives the
|
|
48
|
+
* full entity: a transform or computed field can't read a column the client
|
|
49
|
+
* didn't also select, and on a nested relation not even its own key is
|
|
50
|
+
* guaranteed. Using the `@FieldRequires` registry (populated at bootstrap), each
|
|
51
|
+
* selected field that is a custom resolver contributes its declared source
|
|
52
|
+
* columns; a resolver that declared nothing falls back to the model's scalar
|
|
53
|
+
* columns. Plain columns are fetched as-is, so a query with no custom fields is
|
|
54
|
+
* exactly what was asked for. Relations stay selection-driven — only selected
|
|
55
|
+
* relations are fetched, and each is recursed into so its own resolvers get their
|
|
56
|
+
* columns too. ABAC field omission runs afterward in the proxy and strips
|
|
57
|
+
* `@Role`-restricted columns at select-time, so a caller — and a transform — only
|
|
58
|
+
* ever sees readable columns.
|
|
59
|
+
*
|
|
60
|
+
* Custom field names with no backing column are dropped: `PrismaSelect` puts every
|
|
61
|
+
* requested GraphQL field into the `select`, and passing a name Prisma doesn't
|
|
62
|
+
* recognise throws a validation error. They resolve from the parent afterward.
|
|
63
|
+
*/
|
|
64
|
+
function selectReadColumns(select, info, prisma, modelName) {
|
|
65
|
+
const named = (0, graphql_2.getNamedType)(info.returnType);
|
|
66
|
+
const type = named instanceof graphql_2.GraphQLObjectType ? named : null;
|
|
67
|
+
const requested = select?.select && typeof select.select === 'object' ? select.select : {};
|
|
68
|
+
const resolved = resolveLevelColumns(requested, type, prisma, modelName);
|
|
69
|
+
return { ...(select && typeof select === 'object' ? select : {}), select: resolved };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Per-level worker for {@link selectReadColumns}: resolve the columns fetched for
|
|
73
|
+
* one model, recursing into each selected relation against its own model type.
|
|
74
|
+
*/
|
|
75
|
+
function resolveLevelColumns(requested, type, prisma, modelName) {
|
|
76
|
+
const scalars = prisma.getScalarFields(modelName);
|
|
77
|
+
const scalarSet = new Set(scalars);
|
|
78
|
+
const fields = type ? type.getFields() : undefined;
|
|
79
|
+
const out = {};
|
|
80
|
+
const needed = new Set();
|
|
81
|
+
let fetchAllScalars = false;
|
|
82
|
+
for (const [key, value] of Object.entries(requested)) {
|
|
83
|
+
if (value && typeof value === 'object') {
|
|
84
|
+
// A relation or `_count`. Recurse into a known relation model so its
|
|
85
|
+
// own field resolvers get their source columns (a relation's GraphQL
|
|
86
|
+
// type name is its Prisma model name); keep `_count`/opaque verbatim.
|
|
87
|
+
const relField = fields?.[key];
|
|
88
|
+
if (relField && value.select && typeof value.select === 'object') {
|
|
89
|
+
const relType = (0, graphql_2.getNamedType)(relField.type);
|
|
90
|
+
if (relType instanceof graphql_2.GraphQLObjectType && prisma.getScalarFields(relType.name).length) {
|
|
91
|
+
out[key] = {
|
|
92
|
+
...value,
|
|
93
|
+
select: resolveLevelColumns(value.select, relType, prisma, relType.name),
|
|
94
|
+
};
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
out[key] = value;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (scalarSet.has(key)) {
|
|
102
|
+
out[key] = value; // a real scalar column — keep even if also overridden by a resolver
|
|
103
|
+
}
|
|
104
|
+
// Is this field a custom @ResolveField on this model (new field OR in-place
|
|
105
|
+
// override)? The registry knows, and knows its declared source columns.
|
|
106
|
+
const entry = (0, field_requires_registry_1.getFieldRequires)(modelName, key);
|
|
107
|
+
if (entry) {
|
|
108
|
+
if (entry.requires) {
|
|
109
|
+
for (const col of entry.requires) {
|
|
110
|
+
if (scalarSet.has(col))
|
|
111
|
+
needed.add(col);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
fetchAllScalars = true; // a resolver that declared nothing — fetch all scalars
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// else: a plain column (already added above) or an unknown non-column
|
|
119
|
+
// field name — dropped; it resolves from @Parent() afterward.
|
|
120
|
+
}
|
|
121
|
+
if (fetchAllScalars) {
|
|
122
|
+
for (const field of scalars)
|
|
123
|
+
out[field] = true;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
for (const col of needed)
|
|
127
|
+
out[col] = true;
|
|
128
|
+
}
|
|
129
|
+
// An empty select is invalid Prisma; fall back to the model's scalar columns.
|
|
130
|
+
if (Object.keys(out).length === 0) {
|
|
131
|
+
for (const field of scalars)
|
|
132
|
+
out[field] = true;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
39
136
|
/**
|
|
40
137
|
* Build a **namespaced, read-only** GraphQL resolver for one model.
|
|
41
138
|
*
|
|
@@ -87,11 +184,11 @@ function CoreGraphqlResolver(bundle) {
|
|
|
87
184
|
return {};
|
|
88
185
|
}
|
|
89
186
|
async find(args, info) {
|
|
90
|
-
const select = new plugins_1.PrismaSelect(info).value;
|
|
187
|
+
const select = selectReadColumns(new plugins_1.PrismaSelect(info).value, info, this.prisma, modelName);
|
|
91
188
|
return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).findMany({ ...args, ...select }));
|
|
92
189
|
}
|
|
93
190
|
async get(args, info) {
|
|
94
|
-
const select = new plugins_1.PrismaSelect(info).value;
|
|
191
|
+
const select = selectReadColumns(new plugins_1.PrismaSelect(info).value, info, this.prisma, modelName);
|
|
95
192
|
return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).findFirst({ ...args, ...select }));
|
|
96
193
|
}
|
|
97
194
|
async count(where) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graphql.module.d.ts","sourceRoot":"","sources":["../../src/graphql/graphql.module.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"graphql.module.d.ts","sourceRoot":"","sources":["../../src/graphql/graphql.module.ts"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH,qBACa,oBAAoB;IAK7B,QAAQ,IAAI,MAAM;CAGrB;AAED,qBA0Ba,aAAa;CAAG"}
|
|
@@ -11,8 +11,10 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.GraphqlModule = exports.AppxCoreRootResolver = void 0;
|
|
13
13
|
const common_1 = require("@nestjs/common");
|
|
14
|
+
const core_1 = require("@nestjs/core");
|
|
14
15
|
const graphql_1 = require("@nestjs/graphql");
|
|
15
16
|
const apollo_1 = require("@nestjs/apollo");
|
|
17
|
+
const field_requires_registry_1 = require("./field-requires.registry");
|
|
16
18
|
/**
|
|
17
19
|
* Always-present root query. GraphQL requires a non-empty `Query` type, but a
|
|
18
20
|
* project may register `GraphqlModule` before any model has opted in (models
|
|
@@ -41,6 +43,8 @@ let GraphqlModule = exports.GraphqlModule = class GraphqlModule {
|
|
|
41
43
|
exports.GraphqlModule = GraphqlModule = __decorate([
|
|
42
44
|
(0, common_1.Module)({
|
|
43
45
|
imports: [
|
|
46
|
+
// DiscoveryService/MetadataScanner power the @FieldRequires bootstrap scan.
|
|
47
|
+
core_1.DiscoveryModule,
|
|
44
48
|
graphql_1.GraphQLModule.forRoot({
|
|
45
49
|
driver: apollo_1.ApolloDriver,
|
|
46
50
|
context: ({ req, res }) => ({ req, res }),
|
|
@@ -49,8 +53,17 @@ exports.GraphqlModule = GraphqlModule = __decorate([
|
|
|
49
53
|
// `src/generated/` path at boot (CI, read-only deploys, tests).
|
|
50
54
|
autoSchemaFile: true,
|
|
51
55
|
sortSchema: true,
|
|
56
|
+
// Introspection powers the Apollo Sandbox (GET /graphql). It exposes
|
|
57
|
+
// the schema shape (not data — ABAC still governs every field), so we
|
|
58
|
+
// keep it ON in development for the Sandbox, and OFF in production as
|
|
59
|
+
// defense-in-depth (less recon surface). Opt back in for a production
|
|
60
|
+
// deployment with APPX_GRAPHQL_INTROSPECTION=true. Without this, Apollo
|
|
61
|
+
// Server v4 disables introspection under NODE_ENV=production and the
|
|
62
|
+
// Sandbox fails with `INTROSPECTION_DISABLED`.
|
|
63
|
+
introspection: process.env.NODE_ENV !== 'production' ||
|
|
64
|
+
process.env.APPX_GRAPHQL_INTROSPECTION === 'true',
|
|
52
65
|
}),
|
|
53
66
|
],
|
|
54
|
-
providers: [AppxCoreRootResolver],
|
|
67
|
+
providers: [AppxCoreRootResolver, field_requires_registry_1.FieldRequiresScanner],
|
|
55
68
|
})
|
|
56
69
|
], GraphqlModule);
|
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export * from './common/interfaces/storage-service.interface';
|
|
|
37
37
|
export * from './modules/file/file-upload.controller';
|
|
38
38
|
export * from './modules/file/file-upload.service';
|
|
39
39
|
export * from './common/decorators/permission.decorator';
|
|
40
|
+
export * from './common/decorators/field-requires.decorator';
|
|
40
41
|
export * from './common/setup/setup-core-security';
|
|
41
42
|
export * from './common/setup/core-config';
|
|
42
43
|
export * from './common/interceptors/file.interceptor';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,yBAAyB,CAAC;AACxC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAI3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qCAAqC,CAAC;AACpD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,oCAAoC,CAAC;AACnD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,2CAA2C,CAAC;AAC1D,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uCAAuC,CAAC;AACtD,cAAc,yBAAyB,CAAC;AACxC,cAAc,0CAA0C,CAAC;AACzD,cAAc,4CAA4C,CAAC;AAC3D,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oCAAoC,CAAC;AACnD,cAAc,wCAAwC,CAAC;AACvD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6CAA6C,CAAC;AAC5D,cAAc,oBAAoB,CAAC;AACnC,cAAc,mCAAmC,CAAC;AAClD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,wCAAwC,CAAC;AACvD,cAAc,+CAA+C,CAAC;AAC9D,cAAc,uCAAuC,CAAC;AACtD,cAAc,oCAAoC,CAAC;AACnD,cAAc,0CAA0C,CAAC;AACzD,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oCAAoC,CAAC;AACnD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qCAAqC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,yBAAyB,CAAC;AACxC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAI3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qCAAqC,CAAC;AACpD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,oCAAoC,CAAC;AACnD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,2CAA2C,CAAC;AAC1D,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uCAAuC,CAAC;AACtD,cAAc,yBAAyB,CAAC;AACxC,cAAc,0CAA0C,CAAC;AACzD,cAAc,4CAA4C,CAAC;AAC3D,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oCAAoC,CAAC;AACnD,cAAc,wCAAwC,CAAC;AACvD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6CAA6C,CAAC;AAC5D,cAAc,oBAAoB,CAAC;AACnC,cAAc,mCAAmC,CAAC;AAClD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,wCAAwC,CAAC;AACvD,cAAc,+CAA+C,CAAC;AAC9D,cAAc,uCAAuC,CAAC;AACtD,cAAc,oCAAoC,CAAC;AACnD,cAAc,0CAA0C,CAAC;AACzD,cAAc,8CAA8C,CAAC;AAC7D,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oCAAoC,CAAC;AACnD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qCAAqC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -56,6 +56,7 @@ __exportStar(require("./common/interfaces/storage-service.interface"), exports);
|
|
|
56
56
|
__exportStar(require("./modules/file/file-upload.controller"), exports);
|
|
57
57
|
__exportStar(require("./modules/file/file-upload.service"), exports);
|
|
58
58
|
__exportStar(require("./common/decorators/permission.decorator"), exports);
|
|
59
|
+
__exportStar(require("./common/decorators/field-requires.decorator"), exports);
|
|
59
60
|
__exportStar(require("./common/setup/setup-core-security"), exports);
|
|
60
61
|
__exportStar(require("./common/setup/core-config"), exports);
|
|
61
62
|
__exportStar(require("./common/interceptors/file.interceptor"), exports);
|
|
@@ -229,6 +229,21 @@ export declare class PrismaService {
|
|
|
229
229
|
* @returns The condition with placeholders replaced by actual values.
|
|
230
230
|
*/
|
|
231
231
|
private static replacePlaceholders;
|
|
232
|
+
/**
|
|
233
|
+
* The scalar (and enum) field names of a model, as derived from the Prisma
|
|
234
|
+
* DMMF at bootstrap. Relations are excluded.
|
|
235
|
+
*
|
|
236
|
+
* Used by the GraphQL generic resolver to fetch the model's own columns even
|
|
237
|
+
* when a query only selects a subset, so that a custom `@ResolveField` on the
|
|
238
|
+
* model type (e.g. a signed-URL transform) can read its source column from
|
|
239
|
+
* `@Parent()`. ABAC field omission still runs afterward at select-time, so a
|
|
240
|
+
* `@Role`-restricted column is never actually fetched — callers only ever get
|
|
241
|
+
* the readable projection.
|
|
242
|
+
*
|
|
243
|
+
* @param modelName - The model name (any casing; matched case-insensitively).
|
|
244
|
+
* @returns The model's scalar field names, or `[]` if the model is unknown.
|
|
245
|
+
*/
|
|
246
|
+
getScalarFields(modelName: string): string[];
|
|
232
247
|
/**
|
|
233
248
|
* Retrieves the list of fields to omit based on the user's role.
|
|
234
249
|
* It checks the field configurations stored in `fieldConfigs` and compares them against the role.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -981,6 +981,23 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
981
981
|
}
|
|
982
982
|
return condition;
|
|
983
983
|
}
|
|
984
|
+
/**
|
|
985
|
+
* The scalar (and enum) field names of a model, as derived from the Prisma
|
|
986
|
+
* DMMF at bootstrap. Relations are excluded.
|
|
987
|
+
*
|
|
988
|
+
* Used by the GraphQL generic resolver to fetch the model's own columns even
|
|
989
|
+
* when a query only selects a subset, so that a custom `@ResolveField` on the
|
|
990
|
+
* model type (e.g. a signed-URL transform) can read its source column from
|
|
991
|
+
* `@Parent()`. ABAC field omission still runs afterward at select-time, so a
|
|
992
|
+
* `@Role`-restricted column is never actually fetched — callers only ever get
|
|
993
|
+
* the readable projection.
|
|
994
|
+
*
|
|
995
|
+
* @param modelName - The model name (any casing; matched case-insensitively).
|
|
996
|
+
* @returns The model's scalar field names, or `[]` if the model is unknown.
|
|
997
|
+
*/
|
|
998
|
+
getScalarFields(modelName) {
|
|
999
|
+
return this.fieldConfigs[modelName.toLowerCase()]?.scalarFields ?? [];
|
|
1000
|
+
}
|
|
984
1001
|
/**
|
|
985
1002
|
* Retrieves the list of fields to omit based on the user's role.
|
|
986
1003
|
* It checks the field configurations stored in `fieldConfigs` and compares them against the role.
|
package/docs/graphql.md
CHANGED
|
@@ -162,6 +162,68 @@ Always let the generated resolver serve the data — a custom resolver that reac
|
|
|
162
162
|
|
|
163
163
|
---
|
|
164
164
|
|
|
165
|
+
## Shaping read output (transforms & computed fields)
|
|
166
|
+
|
|
167
|
+
To transform a field on the way out (sign a stored S3 key into a URL, mask a value, format a date) or to add a computed field (`slug`, `fullName`), attach a field resolver to the generated model type in the model's module:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// src/modules/project/project.fields.ts
|
|
171
|
+
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
|
|
172
|
+
import { FieldRequires } from '@appxdigital/appx-core';
|
|
173
|
+
import { Project } from '../../generated/project/project.model';
|
|
174
|
+
import { UrlSigner } from '../../shared/url-signer.service';
|
|
175
|
+
|
|
176
|
+
@Resolver(() => Project)
|
|
177
|
+
export class ProjectFields {
|
|
178
|
+
constructor(private readonly urls: UrlSigner) {}
|
|
179
|
+
|
|
180
|
+
// transform an existing field in place — same name, same type
|
|
181
|
+
@ResolveField(() => String, { name: 'coverKey', nullable: true })
|
|
182
|
+
coverKey(@Parent() p: Project) {
|
|
183
|
+
return p.coverKey ? this.urls.sign(p.coverKey) : null; // stored key → signed URL
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// add a computed field — declare the column(s) it reads
|
|
187
|
+
@ResolveField(() => String, { nullable: true })
|
|
188
|
+
@FieldRequires('name')
|
|
189
|
+
slug(@Parent() p: Project) {
|
|
190
|
+
return p.name ? p.name.toLowerCase().replace(/\s+/g, '-') : null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
```ts
|
|
195
|
+
// project.module.ts
|
|
196
|
+
providers: [ProjectService, ProjectResolver, ProjectFields]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The resolver runs wherever the model appears, including nested — `project { find { coverKey } }` and `projectMember { find { project { coverKey } } }` both return the transformed value.
|
|
200
|
+
|
|
201
|
+
It reads from the already-filtered parent row, so it only ever sees columns the caller's role may read. A field computed from a `@Role`-restricted column receives that column as absent, so a transform cannot surface a value the role can't read.
|
|
202
|
+
|
|
203
|
+
### `@FieldRequires` — declare a computed field's source columns
|
|
204
|
+
|
|
205
|
+
A computed field reads columns from its parent, but the query fetches only the columns the client selected. Declare the columns the field reads so they are fetched even when the client didn't select them:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
@ResolveField(() => String, { nullable: true })
|
|
209
|
+
@FieldRequires('coverKey') // one column
|
|
210
|
+
coverUrl(@Parent() p: Project) { … }
|
|
211
|
+
|
|
212
|
+
@ResolveField(() => String, { nullable: true })
|
|
213
|
+
@FieldRequires(['firstName', 'lastName']) // a list, or @FieldRequires('firstName', 'lastName')
|
|
214
|
+
fullName(@Parent() p: Project) { … }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- **Declared** → only those columns are fetched for the field. Works the same for a computed field, an in-place override, and a field on a nested relation.
|
|
218
|
+
- **Not declared** → all of the model's scalar columns are fetched, so the field can read any of them from its parent.
|
|
219
|
+
- **In-place transform reading only its own column** → needs no declaration; that column is fetched whenever the client selects the field. Declare `@FieldRequires` for any *other* columns it reads.
|
|
220
|
+
|
|
221
|
+
Read from `@Parent()`, not the raw Prisma client — re-querying bypasses ABAC. For key-to-URL transforms, reuse the existing field's name so the raw stored value never leaves the server.
|
|
222
|
+
|
|
223
|
+
> The REST CRUD endpoints return rows as stored; output shaping applies to the GraphQL read API.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
165
227
|
## Not available yet
|
|
166
228
|
|
|
167
229
|
- **Mutations** (create/update/delete) — use REST.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appxdigital/appx-core",
|
|
3
|
-
"version": "0.1.128-beta.
|
|
3
|
+
"version": "0.1.128-beta.5",
|
|
4
4
|
"description": "Appx Core is a library that provides a set of tools to help you build your application faster.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|