@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 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;AAwBrC;;;;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"}
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":"AAKA;;;;;GAKG;AACH,qBACa,oBAAoB;IAK7B,QAAQ,IAAI,MAAM;CAGrB;AAED,qBAca,aAAa;CAAG"}
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';
@@ -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":";AACA,OAAO,EAAC,MAAM,EAAE,YAAY,EAAC,MAAM,gBAAgB,CAAC;AACpD,OAAO,EAAC,qBAAqB,EAAkB,MAAM,yCAAyC,CAAC;AAG/F,OAAO,KAAK,EAAC,YAAY,IAAI,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAGlE,OAAO,EAAC,iBAAiB,EAAC,MAAM,kBAAkB,CAAC;AAEnD,eAAO,MAAM,iBAAiB;mBACX,MAAM,EAAE;EACvB,CAAC;AAEL,KAAK,QAAQ,GAAG,MAAM,aAAa,CAAC;AAEpC,oDAAoD;AACpD,MAAM,MAAM,iBAAiB,GAAG;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF,4EAA4E;AAC5E,KAAK,QAAQ,CAAC,CAAC,EAAE,CAAC,SAAS,MAAM,IAAI,CAAC,SAAS,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;AAEtE;;;;;;;;;GASG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,YAAY,GAAG,mBAAmB,GAAG,QAAQ,GAAG,QAAQ,CAAC,GAAG;IACjG,UAAU,EAAE,QAAQ,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;IACrC,iBAAiB,EAAE,QAAQ,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC;IACnD,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;IAClC,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;CACrC,CAAC;AAGF,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,QAAQ,IAAI,eAAe,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;AAEpF;;;GAGG;AACH,MAAM,MAAM,gBAAgB,CAAC,CAAC,IAAI;KAC7B,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;QAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;KAAE,GAAG,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CACpG,CAAC;AAEF,qBACa,aAAa;IAaY,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAZpE,OAAO,CAAC,YAAY,CAA2B;IAC/C,YAAY,EAAE,YAAY,CAAC;IAC3B;;;;;OAKG;IACH,OAAO,CAAC,SAAS,CAAgB;gBAG7B,YAAY,EAAE,YAAY,EACqB,iBAAiB,EAAE,qBAAqB;IAQ3F;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IA6B3B,YAAY,CAAC,MAAM,EAAE,OAAO;IAO5B,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC;IAWjE,OAAO,CAAC,KAAK;IAQb,YAAY,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,iBAAiB,KAAK,OAAO,CAAC,CAAC,CAAC;IAIpE,WAAW;IAuFX;;;;OAIG;IACH,gBAAgB,CAAC,CAAC,SAAS,QAAQ,EAAE,KAAK,EAAE,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC;IAoBlE,IAAI,KAAK,IAAI,gBAAgB,CAAC,aAAa,CAAC,CAU3C;IAED,IAAI,IAAI,IAAI,eAAe,CAAC,MAAM,CAAC,CAElC;IAED,IAAI,OAAO,IAAI,eAAe,CAAC,SAAS,CAAC,CAExC;IAED,IAAI,gBAAgB,QAEnB;IAED;;;OAGG;IACH,WAAW;IA+DX;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;IAkE1B,IAAI,2BAA2B,QAM9B;IAED;;;;;;;;;;;OAWG;IACH;;;;;;;OAOG;IACH,OAAO,CAAC,0BAA0B;IAYlC,mFAAmF;IACnF,OAAO,CAAC,0BAA0B;IAgDlC;;;;;;OAMG;IACH,OAAO,CAAC,uBAAuB;IAQ/B,OAAO,CAAC,0BAA0B;IAOlC,OAAO,CAAC,oBAAoB;IA8K5B;;;;;;;;;;;;;;;;OAgBG;YACW,uBAAuB;IAkDrC;;;;;;;;;;;;;;;;;;;OAmBG;YACW,mBAAmB;IA2BjC,0EAA0E;YAC5D,wBAAwB;IAmCtC,yGAAyG;IACzG,OAAO,CAAC,gBAAgB;IAWxB,4FAA4F;IAC5F,OAAO,CAAC,eAAe;IAcvB;;;;;;;;OAQG;YACW,oBAAoB;IA6ClC;;;;OAIG;IACH,OAAO,CAAC,SAAS;IAKjB;;;;;;OAMG;YACW,2BAA2B;IAqBzC;;;;;;OAMG;IACH,OAAO,CAAC,yBAAyB;IAmBjC;;;;;;;OAOG;YACW,uBAAuB;IA8BrC,oGAAoG;IACpG,OAAO,CAAC,mBAAmB;IAwB3B;;;;;;;OAOG;WACW,gBAAgB,CAAC,UAAU,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,GAAG,GAAG,GAAG;IAgBjE;;;;;;;OAOG;IACH,OAAO,CAAC,MAAM,CAAC,mBAAmB;IA2BlC;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe;IAUvB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,iBAAiB;IA8EzB,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,IAAI,GAAG;QAC5E,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,UAAU,EAAE,OAAO,CAAA;KACtB;IAoBD,gBAAgB,CAAC,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAyBzF"}
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",
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",