@appxdigital/appx-core 0.1.128-beta.2 → 0.1.128-beta.4

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,12 @@ 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 computed field reads so the generated `find`/`get` fetches exactly those; undeclared computed fields fall back to fetching all scalar columns, and 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`.
34
+
35
+ ### Fixed
36
+
37
+ - **Compound-name models now work over GraphQL.** `getModelDelegate` lowercased the whole model name (`AlbumMember` → `albummember`) instead of just the first character, so it missed Prisma's camelCase delegate key (`albumMember`) and every `find`/`get`/`count` on a model with an internal capital threw `Model <X> not found in PrismaClient.`. Single-word models (`Album`) worked only by coincidence. It now lowercases only the first character, matching Prisma's delegate naming (and the resolver's own root-name rule). REST was never affected. Regression covered by a compound-name model (`ProjectMember`) in `test/http/graphql-abac.spec.ts`.
32
38
 
33
39
  ### Changed / Removed
34
40
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The GraphQL schema-field `extensions` key under which {@link FieldRequires}
3
+ * records the source columns. The generated read resolver reads it back from the
4
+ * schema when deciding which columns to fetch
5
+ * (see `generic.resolver.ts` → `selectReadColumns`).
6
+ */
7
+ export declare const FIELD_REQUIRES_EXTENSION = "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.
12
+ *
13
+ * Use it on a computed field whose value derives from a column — e.g. a signed
14
+ * `coverUrl` built from a stored `coverKey`. Without it, only the columns the
15
+ * client selected are fetched and `@Parent().coverKey` would be `undefined`.
16
+ *
17
+ * - Declared → only the listed columns are fetched for the field.
18
+ * - Not declared → all of the model's scalar columns are fetched.
19
+ * - Not needed for an in-place transform that reuses an existing field's name.
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[])[]) => MethodDecorator & ClassDecorator & PropertyDecorator;
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,wBAAwB,aAAa,CAAC;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,aAAa,eAAgB,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,yDACH,CAAC"}
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FieldRequires = exports.FIELD_REQUIRES_EXTENSION = void 0;
4
+ const graphql_1 = require("@nestjs/graphql");
5
+ /**
6
+ * The GraphQL schema-field `extensions` key under which {@link FieldRequires}
7
+ * records the source columns. The generated read resolver reads it back from the
8
+ * schema when deciding which columns to fetch
9
+ * (see `generic.resolver.ts` → `selectReadColumns`).
10
+ */
11
+ exports.FIELD_REQUIRES_EXTENSION = '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.
16
+ *
17
+ * Use it on a computed field whose value derives from a column — e.g. a signed
18
+ * `coverUrl` built from a stored `coverKey`. Without it, only the columns the
19
+ * client selected are fetched and `@Parent().coverKey` would be `undefined`.
20
+ *
21
+ * - Declared → only the listed columns are fetched for the field.
22
+ * - Not declared → all of the model's scalar columns are fetched.
23
+ * - Not needed for an in-place transform that reuses an existing field's name.
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, graphql_1.Extensions)({ [exports.FIELD_REQUIRES_EXTENSION]: columns.flat() });
54
+ exports.FieldRequires = FieldRequires;
@@ -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;AA4GrC;;;;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_decorator_1 = require("../common/decorators/field-requires.decorator");
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,85 @@ async function readOrBadRequest(op) {
36
38
  throw e;
37
39
  }
38
40
  }
41
+ /**
42
+ * Turn a `PrismaSelect`-derived selection into the columns actually fetched,
43
+ * resolving the source columns that custom `@ResolveField`s need.
44
+ *
45
+ * `PrismaSelect` narrows the Prisma query to exactly the fields the GraphQL
46
+ * request asked for. That breaks the standard NestJS code-first contract where a
47
+ * `@ResolveField` on the model type receives the full entity as `@Parent()`: a
48
+ * custom field (`coverUrl` derived from a `coverKey` column, or a signed-URL
49
+ * transform of an existing field) can't read a source column the client didn't
50
+ * also select. We restore that contract with a **hybrid** strategy that avoids
51
+ * over-fetching:
52
+ *
53
+ * - A custom field can declare its source columns natively, via the standard
54
+ * NestJS field `extensions`:
55
+ * `@ResolveField(() => String, { extensions: { requires: ['coverKey'] } })`
56
+ * When declared, ONLY those columns are added — no over-fetch.
57
+ * - A custom field that declares nothing falls back to fetching all of the
58
+ * model's scalar columns (safe default: the resolver can read anything from
59
+ * `@Parent()`), at the cost of reading columns the query didn't select.
60
+ * - A query that selects no custom fields fetches exactly what was asked for.
61
+ *
62
+ * Relations stay selection-driven throughout (no unbounded relation fetching).
63
+ * ABAC field omission runs afterward in the proxy and strips `@Role`-restricted
64
+ * columns at select-time, so a caller still only ever receives — and a transform
65
+ * only ever sees — the columns their role may read.
66
+ *
67
+ * Custom field names (no backing column) are dropped from the Prisma `select`:
68
+ * `PrismaSelect` puts every requested GraphQL field there, and passing a name
69
+ * Prisma doesn't recognise throws a validation error. They resolve from the
70
+ * parent afterward, so they must not reach the query.
71
+ */
72
+ function selectReadColumns(select, info, prisma, modelName) {
73
+ const scalars = new Set(prisma.getScalarFields(modelName));
74
+ const requested = select?.select && typeof select.select === 'object' ? select.select : {};
75
+ // The GraphQL type of the returned rows, to read each field's
76
+ // `extensions.requires` (find → [Model], get → Model; getNamedType unwraps).
77
+ const named = (0, graphql_2.getNamedType)(info.returnType);
78
+ const gqlFields = named instanceof graphql_2.GraphQLObjectType ? named.getFields() : {};
79
+ const out = {};
80
+ const requiredColumns = new Set();
81
+ let needAllScalars = false;
82
+ for (const [key, value] of Object.entries(requested)) {
83
+ if (value && typeof value === 'object') {
84
+ out[key] = value; // a relation (or `_count`) selection — keep as-is
85
+ }
86
+ else if (scalars.has(key)) {
87
+ out[key] = value; // a real scalar column
88
+ }
89
+ else {
90
+ // A custom field resolver (no backing column). Prefer its declared
91
+ // dependencies (@FieldRequires); otherwise fall back to all scalars.
92
+ const requires = gqlFields[key]?.extensions?.[field_requires_decorator_1.FIELD_REQUIRES_EXTENSION];
93
+ if (Array.isArray(requires)) {
94
+ for (const col of requires) {
95
+ if (scalars.has(col))
96
+ requiredColumns.add(col);
97
+ }
98
+ }
99
+ else {
100
+ needAllScalars = true;
101
+ }
102
+ }
103
+ }
104
+ if (needAllScalars) {
105
+ for (const field of scalars)
106
+ out[field] = true;
107
+ }
108
+ else {
109
+ for (const col of requiredColumns)
110
+ out[col] = true;
111
+ }
112
+ // Guard against an empty select (e.g. only a custom field with `requires: []`)
113
+ // — an empty Prisma `select` is invalid; fall back to all scalars.
114
+ if (Object.keys(out).length === 0) {
115
+ for (const field of scalars)
116
+ out[field] = true;
117
+ }
118
+ return { ...(select && typeof select === 'object' ? select : {}), select: out };
119
+ }
39
120
  /**
40
121
  * Build a **namespaced, read-only** GraphQL resolver for one model.
41
122
  *
@@ -87,11 +168,11 @@ function CoreGraphqlResolver(bundle) {
87
168
  return {};
88
169
  }
89
170
  async find(args, info) {
90
- const select = new plugins_1.PrismaSelect(info).value;
171
+ const select = selectReadColumns(new plugins_1.PrismaSelect(info).value, info, this.prisma, modelName);
91
172
  return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).findMany({ ...args, ...select }));
92
173
  }
93
174
  async get(args, info) {
94
- const select = new plugins_1.PrismaSelect(info).value;
175
+ const select = selectReadColumns(new plugins_1.PrismaSelect(info).value, info, this.prisma, modelName);
95
176
  return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).findFirst({ ...args, ...select }));
96
177
  }
97
178
  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":"AAKA;;;;;GAKG;AACH,qBACa,oBAAoB;IAK7B,QAAQ,IAAI,MAAM;CAGrB;AAED,qBAwBa,aAAa;CAAG"}
@@ -49,6 +49,15 @@ exports.GraphqlModule = GraphqlModule = __decorate([
49
49
  // `src/generated/` path at boot (CI, read-only deploys, tests).
50
50
  autoSchemaFile: true,
51
51
  sortSchema: true,
52
+ // Introspection powers the Apollo Sandbox (GET /graphql). It exposes
53
+ // the schema shape (not data — ABAC still governs every field), so we
54
+ // keep it ON in development for the Sandbox, and OFF in production as
55
+ // defense-in-depth (less recon surface). Opt back in for a production
56
+ // deployment with APPX_GRAPHQL_INTROSPECTION=true. Without this, Apollo
57
+ // Server v4 disables introspection under NODE_ENV=production and the
58
+ // Sandbox fails with `INTROSPECTION_DISABLED`.
59
+ introspection: process.env.NODE_ENV !== 'production' ||
60
+ process.env.APPX_GRAPHQL_INTROSPECTION === 'true',
52
61
  }),
53
62
  ],
54
63
  providers: [AppxCoreRootResolver],
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":"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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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"}
@@ -180,7 +180,15 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
180
180
  * @param model
181
181
  */
182
182
  getModelDelegate(model) {
183
- const modelName = model.toLowerCase();
183
+ // Prisma exposes delegates in camelCase (`client.albumMember`), lowercasing
184
+ // only the FIRST character of the model name. Callers pass the PascalCase
185
+ // class name (`model.name`), so lowercase just the first char — NOT the
186
+ // whole string, which would flatten any internal capital (`AlbumMember` →
187
+ // `albummember`) and miss the delegate. (The framework's own model-name
188
+ // namespace is deliberately whole-lowercase for case-insensitivity, but
189
+ // that is a separate lookup; this one indexes the real Prisma client.)
190
+ const name = model;
191
+ const modelName = name.charAt(0).toLowerCase() + name.slice(1);
184
192
  const client = this.prismaClient;
185
193
  if (modelName in client) {
186
194
  return client[modelName];
@@ -973,6 +981,23 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
973
981
  }
974
982
  return condition;
975
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
+ }
976
1001
  /**
977
1002
  * Retrieves the list of fields to omit based on the user's role.
978
1003
  * It checks the field configurations stored in `fieldConfigs` and compares them against the role.
package/docs/graphql.md CHANGED
@@ -141,7 +141,7 @@ The **generated GraphQL input type is the authoritative allowlist** — an opera
141
141
 
142
142
  Two framework rules on top of that:
143
143
 
144
- - **You can only filter/sort by fields your role can read.** A `where`/`orderBy` on a `@Role`-hidden column is rejected with `403` — except a presence check (`{ field: null }` / `{ NOT: { field: null } }`), which reveals only whether a value is set.
144
+ - **You can only filter/sort by fields your role can read.** A `where`/`orderBy` on a `@Role`-hidden column is rejected with `403`. The **only** exception is a **bare-null presence check** — `{ field: null }`, `{ field: { equals: null } }`, `{ field: { not: null } }`, or `{ NOT: { field: null } }` — which reveals only whether a value is set. **Any value-bearing predicate on a hidden field still `403`s**, including `{ field: { not: "x" } }` and `{ field: { equals: "x" } }` — anything that compares against a real value. `orderBy` and `distinct` on a hidden field are always rejected (no presence exception).
145
145
  - **A malformed value returns a clean `400`.** `createdAt: { gt: 2024 }` (a number where a date is expected) is a `400`, never a `500` or a leaked Prisma error.
146
146
 
147
147
  ---
@@ -153,7 +153,7 @@ All three operations route through `PrismaService.getModelDelegate`, so the rule
153
153
  - **Unauthorized rows are not returned.** `find` returns only rows the role may read; `get` returns `null` for a row the caller can't access even if asked for by unique field; `count` counts only accessible rows.
154
154
  - **Restricted fields are omitted.** A column the role can't read (`/// @Role(...)`) comes back **`null`** even though the GraphQL type exposes it — at the top level *and* nested. (`prisma-nestjs-graphql` types every scalar; the proxy is what strips the values.)
155
155
  - **Inaccessible nested records are filtered.** If a nested relation points at a record the caller can't read, that relation is filtered out — a to-many yields only the visible rows; an inaccessible to-one drops the parent row. You can hold access to a record while a record it links to is withheld.
156
- - **You can only filter/sort by fields you can read.** A `where` / `orderBy` / `distinct` that references a field the role can't read (`@Role`) is rejected with `403` — top level or nested — so a hidden column can't be used to filter or order either. The one exception is a **presence/nullness** check in `where`: `{ field: null }` / `{ NOT: { field: null } }` is allowed (it reveals only whether a value is set, not the value), so `hasX` / `isDeleted` patterns work without a bypass.
156
+ - **You can only filter/sort by fields you can read.** A `where` / `orderBy` / `distinct` that references a field the role can't read (`@Role`) is rejected with `403` — top level or nested. The **only** exception is a **bare-null presence check** in `where` (`{ field: null }` / `{ field: { equals: null } }` / `{ field: { not: null } }` / `{ NOT: { field: null } }`) — it reveals only whether a value is set, so `hasX` / `isDeleted` work without a bypass. A **value-bearing** predicate still `403`s (even `{ field: { not: "x" } }`), and `orderBy`/`distinct` on a hidden field have no presence exception.
157
157
  - **Anonymous callers are `GUEST`.** With no valid `Authorization: Bearer <jwt>` (or session), the request is a GUEST; a model with no GUEST rule is denied.
158
158
 
159
159
  > Because relation records and fields are filtered by ABAC, "empty / null" means "not visible to you", not "does not exist". Design queries and UI accordingly.
@@ -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.
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** (a resolver reusing an existing field's name) → needs no declaration; that column is fetched whenever the client selects the field.
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.2",
3
+ "version": "0.1.128-beta.4",
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",