@appxdigital/appx-core 0.1.128-beta.0 → 0.1.128-beta.1

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
@@ -21,26 +21,27 @@ Read-only GraphQL, reworked into a **namespaced, opt-in-per-module** API.
21
21
  ### Added
22
22
 
23
23
  - **Namespaced read API.** Each exposed model gets one root query field (its camelCase name) with three operations: `find` (→ list), `get` (→ first match or null), `count` (→ Int). Example: `query { project { find(where:{…}){ id } get(where:{…}){ id } count } }`. Short names live on the per-model namespace, so they never collide, aliases work (`recent: find(...)`), and several models can be queried in one request.
24
- - **Opt-in per module — one line, no resolver file.** Expose a model by adding `CoreGraphqlResolver(<Model>Graphql)` to its module `providers` (the generated module ships this commented, copy-paste-ready). `<Model>Graphql` is a generated bundle at `src/generated/<model>/graphql.ts` (deploy-safe pass). Remove the line to un-expose.
24
+ - **Opt-in per module — one line, no resolver file.** Expose a model by importing its generated resolver and adding it to `providers`: `import { <Model>Resolver } from '../../generated/<model>/graphql'; providers: [<Model>Service, <Model>Resolver]`. The generated module ships this commented, copy-paste-ready. `<Model>Resolver` (and the underlying `<Model>Graphql` bundle) live at `src/generated/<model>/graphql.ts`, emitted by the deploy-safe pass. Remove the line to un-expose.
25
25
  - **`/graphql` mounted by default** in new projects (Apollo; Sandbox on `GET /graphql`). The schema stays valid before any model opts in.
26
- - **Same ABAC as REST, verified end-to-end** (`test/http/graphql-abac.spec.ts`): unauthorized rows aren't returned (incl. `count`, which is filtered — no total leak), restricted fields come back null at top level and nested (`@Role`), and an inaccessible nested **record** is filtered while the parent stays accessible. GraphQL requests now populate the user across transports (`UserPopulationGuard` uses `transformContext`).
26
+ - **Same ABAC as REST, verified end-to-end** (`test/http/graphql-abac.spec.ts`): unauthorized rows aren't returned (`count` included — it's filtered to accessible rows), restricted fields (`@Role`) come back null at the top level and nested, and an inaccessible nested **record** is filtered while the parent stays accessible. GraphQL requests now populate the user across transports (`UserPopulationGuard` uses `transformContext`).
27
+ - **Filter/sort limited to readable fields.** A client `where` / `orderBy` / `distinct` that references a field the caller's role can't read is **rejected** (`403`), recursing through relations and `AND`/`OR`/`NOT` — the input-side counterpart to output field omission, applied in `PrismaService` for every transport.
27
28
 
28
29
  ### Changed / Removed
29
30
 
30
- - The old root-level `findAll<Model>s` / `findOne<Model>` / `findFirst<Model>` queries and the `GenericResolverFactory` export are **removed**, replaced by `CoreGraphqlResolver`.
31
+ - The old root-level `findAll<Model>s` / `findOne<Model>` / `findFirst<Model>` queries and the `GenericResolverFactory` export are **removed**, replaced by the generated per-model `<Model>Resolver` (built on the exported `CoreGraphqlResolver`).
31
32
  - `appx-core generate models` **no longer scaffolds a per-module `*.resolver.ts` file**; GraphQL is opt-in via the one-liner above.
33
+ - **`@apollo/server` is now a declared peer dependency** (`GraphqlModule` mounts Apollo by default). A normal `npm install` pulls it — no manual install.
32
34
 
33
35
  ### Migration
34
36
 
35
37
  > **Behaviour change — the GraphQL resolver API changed and per-module resolver files are no longer generated.** Only relevant if you use GraphQL.
36
38
 
37
39
  1. **Delete the generated `src/modules/<model>/<model>.resolver.ts` files** — they import the removed `GenericResolverFactory` and will not compile. Remove `<Model>Resolver` from each module's `providers`.
38
- 2. Run **`appx-core generate`** to emit the new `src/generated/<model>/graphql.ts` bundles.
40
+ 2. Run **`appx-core generate`** to emit the new `src/generated/<model>/graphql.ts` bundles, then **`npm install`** (pulls the newly-declared `@apollo/server` peer that `GraphqlModule` needs).
39
41
  3. Add **`GraphqlModule`** to your `app.module` imports (new projects already have it): `import { GraphqlModule } from '@appxdigital/appx-core';`.
40
- 4. **Expose each model** you want in GraphQL by adding one provider line to its module: `CoreGraphqlResolver(<Model>Graphql)` (import `<Model>Graphql` from `../../generated/<model>/graphql`). See [docs/graphql.md](./docs/graphql.md).
42
+ 4. **Expose each model** you want in GraphQL by adding one provider line to its module: import `<Model>Resolver` from `../../generated/<model>/graphql` and add it to `providers`. See [docs/graphql.md](./docs/graphql.md).
41
43
  5. **Update clients:** `findAllProjects(...)` → `project { find(...) }`, `findFirstProject` / `findOneProject` → `project { get(...) }`. Mutations remain REST-only.
42
-
43
- > **Security caveat (unchanged, still open):** generated `where`/`orderBy` inputs include `@Role`/`@NoWrite` columns (a filter-oracle; output values are omitted). Don't expose models with sensitive filterable columns to untrusted roles until the input scrub lands.
44
+ 6. **If a client filtered or ordered by a field a role can't read, that now returns `403`** — move such queries to a role that may read the field, or don't reference it.
44
45
 
45
46
  ---
46
47
 
package/README.md CHANGED
@@ -81,7 +81,11 @@ AppX Core puts **security and data-access control first**, while keeping develop
81
81
  - JWT auth (recommended)
82
82
  - **Fast CRUD acceleration**
83
83
  - Generated modules, controllers, and services per model
84
- - Standard REST CRUD endpoints
84
+ - Standard REST CRUD endpoints (the write surface)
85
+ - **GraphQL read API — recommended for querying**
86
+ - Namespaced, **opt-in per model**: `model { find get count }`
87
+ - **Native pagination** (`take` / `skip` / `cursor`), nested relations, and several models per request — prefer it over the CRUD list endpoint for reads/filtering, and enable it on your main queryable models
88
+ - Same ABAC as REST (row / field / nested-record filtering; filter & sort limited to readable fields)
85
89
  - **Developer-friendly extension points**
86
90
  - Add custom endpoints with `@Permission(...)`
87
91
  - Use services for business logic, controllers as transport layer
@@ -614,7 +618,7 @@ Operational guidance:
614
618
 
615
619
  ### GraphQL status
616
620
 
617
- GraphQL exposes a **read-only** API — `find` / `get` / `count`, namespaced per model and **opt-in per module** (one line) — that runs through the same ABAC proxy as REST, so row filtering, field omission, and nested-relation filtering all apply. See **[docs/graphql.md](./docs/graphql.md)**. Not yet: mutations and full aggregates. One caveat before exposing sensitive models to untrusted roles — the generated `where`/`orderBy` inputs still include restricted columns (a filter-oracle; output values are omitted). REST CRUD + AdminJS remain the primary write surfaces.
621
+ GraphQL exposes a **read-only** API — `find` / `get` / `count`, namespaced per model and **opt-in per module** (one line) — that runs through the same ABAC proxy as REST: row filtering, field omission, nested-relation filtering, and filter/sort limited to fields the role can read all apply. It supports nested selections and native pagination (`take`/`skip`/`cursor`), so it's the recommended surface for querying/filtering/paginating reads. See **[docs/graphql.md](./docs/graphql.md)**. Not yet: mutations and full aggregates — REST CRUD + AdminJS remain the write surfaces.
618
622
 
619
623
  ---
620
624
 
@@ -1 +1 @@
1
- {"version":3,"file":"generate-graphql.d.ts","sourceRoot":"","sources":["../../src/config/generate-graphql.ts"],"names":[],"mappings":"AAmCA;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,EAAE,GAAG,IAAI,CAUxE"}
1
+ {"version":3,"file":"generate-graphql.d.ts","sourceRoot":"","sources":["../../src/config/generate-graphql.ts"],"names":[],"mappings":"AA6CA;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,EAAE,GAAG,IAAI,CAUxE"}
@@ -41,11 +41,11 @@ const generatedRoot = path.join(process.cwd(), 'src/generated');
41
41
  */
42
42
  const bundleTemplate = (model, folder) => {
43
43
  const camel = model.charAt(0).toLowerCase() + model.slice(1);
44
- return `import { ${model} } from './${folder}.model';
44
+ return `import { CoreGraphqlResolver, GraphqlModelBundle } from '@appxdigital/appx-core';
45
+ import { ${model} } from './${folder}.model';
45
46
  import { FindMany${model}Args } from './find-many-${folder}.args';
46
47
  import { FindFirst${model}Args } from './find-first-${folder}.args';
47
48
  import { ${model}WhereInput } from './${folder}-where.input';
48
- import type { GraphqlModelBundle } from '@appxdigital/appx-core';
49
49
 
50
50
  export const ${model}Graphql: GraphqlModelBundle<${model}> = {
51
51
  model: ${model},
@@ -54,6 +54,16 @@ export const ${model}Graphql: GraphqlModelBundle<${model}> = {
54
54
  findFirstArgs: FindFirst${model}Args,
55
55
  whereInput: ${model}WhereInput,
56
56
  };
57
+
58
+ /**
59
+ * Ready-to-register read-only GraphQL resolver for ${model} (\`${camel} { find get count }\`).
60
+ * Add it to your ${model} module's \`providers\` to expose the model — no CoreGraphqlResolver
61
+ * import or resolver file needed:
62
+ *
63
+ * import { ${model}Resolver } from '../../generated/${folder}/graphql';
64
+ * providers: [${model}Service, ${model}Resolver]
65
+ */
66
+ export const ${model}Resolver = CoreGraphqlResolver(${model}Graphql);
57
67
  `;
58
68
  };
59
69
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"generate-modules.d.ts","sourceRoot":"","sources":["../../src/config/generate-modules.ts"],"names":[],"mappings":"AAoCA,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAItD;AAyCD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,2BAA2B,CACvC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAAE,GACrB;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAC,CAkDpC;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI,CASrE"}
1
+ {"version":3,"file":"generate-modules.d.ts","sourceRoot":"","sources":["../../src/config/generate-modules.ts"],"names":[],"mappings":"AAmCA,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAItD;AAyCD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,2BAA2B,CACvC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAAE,GACrB;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAC,CAkDpC;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI,CASrE"}
@@ -42,16 +42,15 @@ import { PrismaModule } from '../../prisma/prisma.module';
42
42
  import { ${model}Controller } from './${folder}.controller';
43
43
  import { ${model}Service } from './${folder}.service';
44
44
  // To expose this model over GraphQL (read-only: \`${camel} { find get count }\`),
45
- // uncomment the two imports and the provider line below. See docs/graphql.md.
46
- // import { CoreGraphqlResolver } from '@appxdigital/appx-core';
47
- // import { ${model}Graphql } from '../../generated/${folder}/graphql';
45
+ // uncomment the import and the provider line below. See docs/graphql.md.
46
+ // import { ${model}Resolver } from '../../generated/${folder}/graphql';
48
47
 
49
48
  @Module({
50
49
  imports: [PrismaModule],
51
50
  controllers: [${model}Controller],
52
51
  providers: [
53
52
  ${model}Service,
54
- // CoreGraphqlResolver(${model}Graphql),
53
+ // ${model}Resolver,
55
54
  ],
56
55
  })
57
56
  export class ${model}Module {}
@@ -102,6 +102,18 @@ export declare class PrismaService {
102
102
  * @returns The modified query arguments with the appropriate `where` conditions applied.
103
103
  * @throws ForbiddenException if no valid `where` conditions are present after applying permissions.
104
104
  */
105
+ /**
106
+ * Reject a client `where` / `orderBy` / `distinct` that references a field
107
+ * this role cannot read (per `@Role` / `getFieldsToOmit`) — the input-side
108
+ * counterpart to `applyFieldOmission` on output, so a field the caller can't
109
+ * read can't be used to filter or sort either. Recurses through relations and
110
+ * AND/OR/NOT. Runs on the CLIENT args, before the trusted author-defined
111
+ * conditions are injected, so it never blocks the framework's own conditions.
112
+ */
113
+ private assertFilterFieldsReadable;
114
+ /** Recursively assert no omitted field is referenced in a where/orderBy clause. */
115
+ private assertClauseFieldsReadable;
116
+ private throwUnreadableFilterField;
105
117
  private applyWhereConditions;
106
118
  /**
107
119
  * Enforces permission `conditions` on `create` / `createMany`.
@@ -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":";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;IAYlE,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;IA0ClC,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"}
@@ -136,6 +136,11 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
136
136
  await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
137
137
  }
138
138
  else {
139
+ // Reject a client where/orderBy/distinct that references a
140
+ // field this role can't read (input counterpart to output
141
+ // field omission). Runs on the client input, BEFORE the
142
+ // trusted ABAC conditions are injected below.
143
+ this.assertFilterFieldsReadable(String(propKey), userRole, params);
139
144
  params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
140
145
  // An update can re-point a foreign key (scalar column);
141
146
  // authorize those changes with the same connect rules as create.
@@ -342,6 +347,70 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
342
347
  * @returns The modified query arguments with the appropriate `where` conditions applied.
343
348
  * @throws ForbiddenException if no valid `where` conditions are present after applying permissions.
344
349
  */
350
+ /**
351
+ * Reject a client `where` / `orderBy` / `distinct` that references a field
352
+ * this role cannot read (per `@Role` / `getFieldsToOmit`) — the input-side
353
+ * counterpart to `applyFieldOmission` on output, so a field the caller can't
354
+ * read can't be used to filter or sort either. Recurses through relations and
355
+ * AND/OR/NOT. Runs on the CLIENT args, before the trusted author-defined
356
+ * conditions are injected, so it never blocks the framework's own conditions.
357
+ */
358
+ assertFilterFieldsReadable(modelName, userRole, args) {
359
+ if (!args || typeof args !== 'object')
360
+ return;
361
+ if (args.where)
362
+ this.assertClauseFieldsReadable(modelName, userRole, args.where, 'where');
363
+ if (args.orderBy)
364
+ this.assertClauseFieldsReadable(modelName, userRole, args.orderBy, 'orderBy');
365
+ if (args.distinct) {
366
+ const omit = new Set(this.getFieldsToOmit(modelName, userRole));
367
+ for (const field of [].concat(args.distinct)) {
368
+ if (omit.has(field))
369
+ this.throwUnreadableFilterField(modelName, field, userRole, 'distinct');
370
+ }
371
+ }
372
+ }
373
+ /** Recursively assert no omitted field is referenced in a where/orderBy clause. */
374
+ assertClauseFieldsReadable(modelName, userRole, clause, kind) {
375
+ if (Array.isArray(clause)) {
376
+ for (const c of clause)
377
+ this.assertClauseFieldsReadable(modelName, userRole, c, kind);
378
+ return;
379
+ }
380
+ if (!clause || typeof clause !== 'object')
381
+ return;
382
+ const omit = new Set(this.getFieldsToOmit(modelName, userRole));
383
+ for (const key of Object.keys(clause)) {
384
+ const value = clause[key];
385
+ // Logical grouping — recurse on the same model.
386
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
387
+ this.assertClauseFieldsReadable(modelName, userRole, value, kind);
388
+ continue;
389
+ }
390
+ // Direct reference to a field the role can't read.
391
+ if (omit.has(key)) {
392
+ this.throwUnreadableFilterField(modelName, key, userRole, kind);
393
+ }
394
+ // Relation — recurse into the related model (unwrapping list/one filters).
395
+ const relation = this.getRelation(modelName, key);
396
+ if (relation) {
397
+ const wrappers = ['some', 'every', 'none', 'is', 'isNot'];
398
+ const wrapped = value && typeof value === 'object' ? wrappers.filter((w) => w in value) : [];
399
+ if (wrapped.length) {
400
+ for (const w of wrapped)
401
+ this.assertClauseFieldsReadable(relation.model, userRole, value[w], kind);
402
+ }
403
+ else {
404
+ this.assertClauseFieldsReadable(relation.model, userRole, value, kind);
405
+ }
406
+ }
407
+ // Otherwise: a readable scalar field. Its value is an operator object
408
+ // (where) or 'asc'/'desc' (orderBy) — not a field map, so no recursion.
409
+ }
410
+ }
411
+ throwUnreadableFilterField(modelName, field, userRole, kind) {
412
+ throw new common_1.HttpException(`Cannot ${kind === 'orderBy' ? 'order by' : 'filter by'} '${modelName}.${field}' — not readable by role ${userRole}.`, common_1.HttpStatus.FORBIDDEN);
413
+ }
345
414
  applyWhereConditions(modelName, userRole, args, user, action) {
346
415
  if (!args) {
347
416
  args = {};
package/docs/graphql.md CHANGED
@@ -4,6 +4,8 @@ AppX Core exposes a **read-only GraphQL API** generated from your Prisma schema.
4
4
 
5
5
  > **Scope:** **read only** — `find`, `get`, `count`. Writes (create/update/delete) are **not** exposed; use the REST CRUD endpoints. See [limitations.md](./limitations.md).
6
6
 
7
+ > **Recommended for reads.** For querying, filtering, and **pagination** (native `take` / `skip` / `cursor`) prefer GraphQL over the generated CRUD list endpoint — one request can select several models, alias repeated queries, and pull nested relations in a single round-trip. Enable it on your main queryable models (one line per module, below); keep REST for writes.
8
+
7
9
  ---
8
10
 
9
11
  ## The endpoint
@@ -30,21 +32,20 @@ import { Module } from '@nestjs/common';
30
32
  import { PrismaModule } from '../../prisma/prisma.module';
31
33
  import { AlbumController } from './album.controller';
32
34
  import { AlbumService } from './album.service';
33
- import { CoreGraphqlResolver } from '@appxdigital/appx-core';
34
- import { AlbumGraphql } from '../../generated/album/graphql'; // generated by `appx-core generate`
35
+ import { AlbumResolver } from '../../generated/album/graphql'; // generated by `appx-core generate`
35
36
 
36
37
  @Module({
37
38
  imports: [PrismaModule],
38
39
  controllers: [AlbumController],
39
40
  providers: [
40
41
  AlbumService,
41
- CoreGraphqlResolver(AlbumGraphql), // ← add = exposed; remove = not exposed
42
+ AlbumResolver, // ← add = exposed; remove = not exposed
42
43
  ],
43
44
  })
44
45
  export class AlbumModule {}
45
46
  ```
46
47
 
47
- `AlbumGraphql` is a small bundle of generated types emitted by the deploy-safe pass (`appx-core generate`) at `src/generated/album/graphql.ts` — regenerated from the schema, never hand-edited.
48
+ `AlbumResolver` is a ready-to-register resolver class emitted by the deploy-safe pass (`appx-core generate`) at `src/generated/album/graphql.ts` — regenerated from the schema, never hand-edited. (The same file exports the underlying `AlbumGraphql` bundle if you'd rather build the resolver yourself via the exported `CoreGraphqlResolver`.)
48
49
 
49
50
  ---
50
51
 
@@ -112,6 +113,7 @@ All three operations route through `PrismaService.getModelDelegate`, so the rule
112
113
  - **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.
113
114
  - **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.)
114
115
  - **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.
116
+ - **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.
115
117
  - **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.
116
118
 
117
119
  > 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.
@@ -125,8 +127,6 @@ Always let the generated resolver serve the data — a custom resolver that reac
125
127
  - **Mutations** (create/update/delete) — use REST.
126
128
  - **Full `aggregate`** (sum/avg/min/max/groupBy) — only `count` is exposed today.
127
129
 
128
- > **Security note (before exposing sensitive models widely).** `prisma-nestjs-graphql` generates `where` / `orderBy` inputs that include *every* scalar, ignoring `/// @Role` / `/// @NoWrite`. Field **values** are omitted on output, but a `where`/`orderBy` over a restricted column (e.g. filtering by a password hash prefix) can still act as an oracle. Scrubbing restricted columns from the generated inputs is tracked as a follow-up; until then, avoid exposing models with sensitive filterable columns to untrusted roles.
129
-
130
130
  ---
131
131
 
132
132
  ## After changing the schema
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.128-beta.0",
3
+ "version": "0.1.128-beta.1",
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",
@@ -59,6 +59,7 @@
59
59
  "@adminjs/nestjs": "~7.0.0",
60
60
  "@adminjs/passwords": "~4.0.0",
61
61
  "@adminjs/prisma": "~5.0.4",
62
+ "@apollo/server": "^4.11.3",
62
63
  "@as-integrations/express5": "~1.1.2",
63
64
  "@aws-sdk/client-s3": "~3.850.0",
64
65
  "@aws-sdk/s3-request-presigner": "~3.850.0",