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

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
@@ -24,7 +24,11 @@ Read-only GraphQL, reworked into a **namespaced, opt-in-per-module** API.
24
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
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
+ - **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 (REST and GraphQL)**. Exception: a **presence/nullness** check on a hidden field — `{ field: null }`, `{ not: null }`, `{ NOT: { field: null } }` — is **allowed**, since it reveals only whether a value is set, not the value (the `hasX` / `isDeleted` pattern, without `BYPASS_OMISSION`). Value-bearing operators (`equals: "x"`, `not: "x"`, `contains`, `gt`, …) stay rejected.
28
+ - **Slimmer generated output.** The deploy-safe pass now prunes the `prisma-nestjs-graphql` types the read-only API never imports (create/update/upsert/delete/aggregate/groupBy inputs), keeping only the model + `find`/`where`/`orderBy` read types reachable from the generated resolvers — roughly **80% fewer files** under `src/generated/`, so `tsc` and your editor do far less work. The DTO tree (`src/generated/dto/`) is untouched.
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
+ - **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
+ - **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.
28
32
 
29
33
  ### Changed / Removed
30
34
 
@@ -41,7 +45,11 @@ Read-only GraphQL, reworked into a **namespaced, opt-in-per-module** API.
41
45
  3. Add **`GraphqlModule`** to your `app.module` imports (new projects already have it): `import { GraphqlModule } from '@appxdigital/appx-core';`.
42
46
  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).
43
47
  5. **Update clients:** `findAllProjects(...)` → `project { find(...) }`, `findFirstProject` / `findOneProject` → `project { get(...) }`. Mutations remain REST-only.
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.
48
+ 6. **Filtering/ordering by a `@Role`-restricted column now returns `403` — on EVERY transport, REST included** (the proxy is the enforcement point, so this is not GraphQL-only). An existing `where`/`orderBy` that touches a hidden column will 403.
49
+ - **Presence checks still work:** `{ field: null }` / `{ NOT: { field: null } }` (e.g. `hasPassword`, `isDeleted`) are allowed — they don't reveal the value.
50
+ - **If you need the actual value**, read it via `BYPASS_OMISSION` and filter in JS, or move the query to a role permitted to read the column.
51
+ 7. **If your own code imported a generated write/aggregate GraphQL input** (e.g. `<Model>CreateInput`, `<Model>AggregateArgs`), it's no longer emitted (the read-only API doesn't use it). Use the REST DTOs under `src/modules/<model>/dto/` for writes.
52
+ 8. **Nested to-many relations are no longer selectable in GraphQL** (`project { tasks { … } }` errors). Move a nested list to a top-level query — `task { find(where: { projectId: { equals: … } }, take, skip) }` — or use `_count` for a count. To-one relations (`project { owner { … } }`) are unaffected.
45
53
 
46
54
  ---
47
55
 
@@ -1,3 +1,9 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ /**
3
+ * The deploy-safe pass, as a single reusable unit. `generate models` (the
4
+ * wizard) runs it too before scaffolding, so keeping it here — rather than
5
+ * duplicating the steps — stops the two paths from drifting (e.g. one pruning
6
+ * the GraphQL types and the other regenerating them unpruned).
7
+ */
8
+ export declare function runDeploySafePass(): void;
3
9
  //# sourceMappingURL=generate-all.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"generate-all.d.ts","sourceRoot":"","sources":["../../src/config/generate-all.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"generate-all.d.ts","sourceRoot":"","sources":["../../src/config/generate-all.ts"],"names":[],"mappings":";AAoBA;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAoBxC"}
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.runDeploySafePass = void 0;
4
5
  /**
5
6
  * `appx generate` — the DEPLOY-SAFE pass.
6
7
  *
@@ -18,12 +19,30 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
19
  const utils_1 = require("./utils");
19
20
  const generate_dtos_1 = require("./generate-dtos");
20
21
  const generate_graphql_1 = require("./generate-graphql");
21
- console.log('Running Prisma Generate (client + GraphQL artifacts)...');
22
- // Project-local prisma: the CLI is usually global, so PATH has no project .bin.
23
- (0, utils_1.runProjectBin)('prisma', 'generate');
24
- const allModels = (0, utils_1.loadAllModels)();
25
- console.log('Generating DTO base classes...');
26
- (0, generate_dtos_1.generateDtoBases)(allModels);
27
- console.log('Generating GraphQL bundles...');
28
- (0, generate_graphql_1.generateGraphqlBundles)(allModels);
29
- console.log('Deploy-safe generation complete — src/generated only, no code changes.');
22
+ /**
23
+ * The deploy-safe pass, as a single reusable unit. `generate models` (the
24
+ * wizard) runs it too before scaffolding, so keeping it here — rather than
25
+ * duplicating the steps — stops the two paths from drifting (e.g. one pruning
26
+ * the GraphQL types and the other regenerating them unpruned).
27
+ */
28
+ function runDeploySafePass() {
29
+ console.log('Running Prisma Generate (client + GraphQL artifacts)...');
30
+ // Project-local prisma: the CLI is usually global, so PATH has no project .bin.
31
+ (0, utils_1.runProjectBin)('prisma', 'generate');
32
+ const allModels = (0, utils_1.loadAllModels)();
33
+ console.log('Generating DTO base classes...');
34
+ (0, generate_dtos_1.generateDtoBases)(allModels);
35
+ console.log('Generating GraphQL bundles...');
36
+ (0, generate_graphql_1.generateGraphqlBundles)(allModels);
37
+ console.log('Hiding to-many relations from GraphQL models (nested lists have no pagination)...');
38
+ (0, generate_graphql_1.hideListRelationsFromModels)(allModels);
39
+ console.log('Pruning unused GraphQL types (read-only API)...');
40
+ (0, generate_graphql_1.pruneGeneratedGraphql)();
41
+ console.log('Deploy-safe generation complete — src/generated only, no code changes.');
42
+ }
43
+ exports.runDeploySafePass = runDeploySafePass;
44
+ // Only run when invoked directly (`node generate-all.js`), not when imported by
45
+ // the wizard (which calls runDeploySafePass() itself).
46
+ if (require.main === module) {
47
+ runDeploySafePass();
48
+ }
@@ -7,4 +7,37 @@
7
7
  export declare function generateGraphqlBundles(allModels: {
8
8
  name: string;
9
9
  }[]): void;
10
+ /**
11
+ * `prisma-nestjs-graphql` emits the full CRUD surface per model
12
+ * (create/update/upsert/delete/aggregate/groupBy inputs), but the read-only
13
+ * GraphQL API only ever imports the model + find/where/orderBy read types. Prune
14
+ * the rest so `src/generated/` stays small and `tsc` doesn't compile dozens of
15
+ * unused files per model.
16
+ *
17
+ * Method: keep the transitive relative-import closure of the generated
18
+ * `graphql.ts` bundles (the read entrypoints), delete every other `.ts` under
19
+ * the model folders + shared `prisma/` folder. The DTO tree (`dto/`, generated
20
+ * by this framework and independent of these types) is preserved untouched.
21
+ */
22
+ /**
23
+ * Hide **to-many (list) relations** from the generated GraphQL model types.
24
+ *
25
+ * Nested selection has no pagination, so selecting a to-many relation
26
+ * (`project { tasks { … } }`) could pull an unbounded number of rows. To-one
27
+ * relations (`project { owner { … } }`) return a single record and stay
28
+ * exposed; `_count` (relation counts, bounded) stays too. Fetch a list at the
29
+ * top level instead — `task { find(where: { projectId … }, take, skip) }` —
30
+ * where pagination + ABAC apply.
31
+ *
32
+ * Implementation: for each model, use the DMMF to find its `kind === 'object'
33
+ * && isList` fields and remove those `@Field(...) name?: Array<…>;` blocks from
34
+ * `<model>.model.ts`, then drop any import left unused. Only the OUTPUT model is
35
+ * touched — `where`/`orderBy` relation filters are unchanged (filtering by a
36
+ * relation returns the parent rows, not the list).
37
+ */
38
+ export declare function hideListRelationsFromModels(allModels: {
39
+ name: string;
40
+ fields: any[];
41
+ }[]): void;
42
+ export declare function pruneGeneratedGraphql(): void;
10
43
  //# sourceMappingURL=generate-graphql.d.ts.map
@@ -1 +1 @@
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"}
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;AAED;;;;;;;;;;;GAWG;AACH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,2BAA2B,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,GAAG,EAAE,CAAA;CAAC,EAAE,GAAG,IAAI,CAoC5F;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CA4D5C"}
@@ -23,7 +23,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
23
23
  return result;
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.generateGraphqlBundles = void 0;
26
+ exports.pruneGeneratedGraphql = exports.hideListRelationsFromModels = exports.generateGraphqlBundles = void 0;
27
27
  const fs = __importStar(require("fs"));
28
28
  const path = __importStar(require("path"));
29
29
  const utils_1 = require("./utils");
@@ -84,3 +84,139 @@ function generateGraphqlBundles(allModels) {
84
84
  }
85
85
  }
86
86
  exports.generateGraphqlBundles = generateGraphqlBundles;
87
+ /**
88
+ * `prisma-nestjs-graphql` emits the full CRUD surface per model
89
+ * (create/update/upsert/delete/aggregate/groupBy inputs), but the read-only
90
+ * GraphQL API only ever imports the model + find/where/orderBy read types. Prune
91
+ * the rest so `src/generated/` stays small and `tsc` doesn't compile dozens of
92
+ * unused files per model.
93
+ *
94
+ * Method: keep the transitive relative-import closure of the generated
95
+ * `graphql.ts` bundles (the read entrypoints), delete every other `.ts` under
96
+ * the model folders + shared `prisma/` folder. The DTO tree (`dto/`, generated
97
+ * by this framework and independent of these types) is preserved untouched.
98
+ */
99
+ /**
100
+ * Hide **to-many (list) relations** from the generated GraphQL model types.
101
+ *
102
+ * Nested selection has no pagination, so selecting a to-many relation
103
+ * (`project { tasks { … } }`) could pull an unbounded number of rows. To-one
104
+ * relations (`project { owner { … } }`) return a single record and stay
105
+ * exposed; `_count` (relation counts, bounded) stays too. Fetch a list at the
106
+ * top level instead — `task { find(where: { projectId … }, take, skip) }` —
107
+ * where pagination + ABAC apply.
108
+ *
109
+ * Implementation: for each model, use the DMMF to find its `kind === 'object'
110
+ * && isList` fields and remove those `@Field(...) name?: Array<…>;` blocks from
111
+ * `<model>.model.ts`, then drop any import left unused. Only the OUTPUT model is
112
+ * touched — `where`/`orderBy` relation filters are unchanged (filtering by a
113
+ * relation returns the parent rows, not the list).
114
+ */
115
+ function hideListRelationsFromModels(allModels) {
116
+ for (const model of allModels) {
117
+ const listRelations = (model.fields || [])
118
+ .filter((f) => f.kind === 'object' && f.isList)
119
+ .map((f) => f.name);
120
+ if (listRelations.length === 0)
121
+ continue;
122
+ const folder = (0, utils_1.modelFolder)(model.name);
123
+ const modelFile = path.join(generatedRoot, folder, `${folder}.model.ts`);
124
+ if (!fs.existsSync(modelFile))
125
+ continue;
126
+ let lines = fs.readFileSync(modelFile, 'utf8').split('\n');
127
+ for (const field of listRelations) {
128
+ const propIdx = lines.findIndex((l) => new RegExp(`^\\s*${field}[?!]?\\s*:`).test(l));
129
+ if (propIdx === -1)
130
+ continue;
131
+ let start = propIdx;
132
+ while (start - 1 >= 0 && /^\s*@/.test(lines[start - 1]))
133
+ start--; // preceding decorators
134
+ let end = propIdx;
135
+ if (lines[end + 1] === '')
136
+ end++; // absorb the trailing blank line
137
+ lines.splice(start, end - start + 1);
138
+ }
139
+ // Drop imports left unused after the removals (each import is a single name).
140
+ let content = lines.join('\n');
141
+ const importRe = /^import\s*\{\s*([A-Za-z0-9_]+)\s*\}\s*from\s*['"][^'"]+['"];?\s*$/;
142
+ content = content
143
+ .split('\n')
144
+ .filter((line) => {
145
+ const m = line.match(importRe);
146
+ if (!m)
147
+ return true;
148
+ return new RegExp(`\\b${m[1]}\\b`).test(content.replace(line, ''));
149
+ })
150
+ .join('\n');
151
+ fs.writeFileSync(modelFile, content);
152
+ }
153
+ }
154
+ exports.hideListRelationsFromModels = hideListRelationsFromModels;
155
+ function pruneGeneratedGraphql() {
156
+ if (!fs.existsSync(generatedRoot))
157
+ return;
158
+ const dtoDir = path.resolve(generatedRoot, 'dto');
159
+ // Every prunable .ts under src/generated, excluding the DTO tree.
160
+ const allFiles = new Set();
161
+ const walk = (dir) => {
162
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
163
+ const p = path.join(dir, entry.name);
164
+ if (entry.isDirectory()) {
165
+ if (path.resolve(p) === dtoDir)
166
+ continue; // preserve DTOs
167
+ walk(p);
168
+ }
169
+ else if (entry.name.endsWith('.ts')) {
170
+ allFiles.add(path.resolve(p));
171
+ }
172
+ }
173
+ };
174
+ walk(generatedRoot);
175
+ const roots = [...allFiles].filter((f) => path.basename(f) === 'graphql.ts');
176
+ if (roots.length === 0)
177
+ return; // nothing to anchor the closure — leave as-is
178
+ // BFS the relative-import graph from the bundles.
179
+ const keep = new Set();
180
+ const queue = [...roots];
181
+ const importRe = /(?:from|import)\s+['"](\.[^'"]+)['"]/g;
182
+ while (queue.length) {
183
+ const file = queue.pop();
184
+ if (keep.has(file))
185
+ continue;
186
+ keep.add(file);
187
+ let src;
188
+ try {
189
+ src = fs.readFileSync(file, 'utf8');
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ importRe.lastIndex = 0;
195
+ let m;
196
+ while ((m = importRe.exec(src)) !== null) {
197
+ const spec = m[1].endsWith('.ts') ? m[1] : `${m[1]}.ts`;
198
+ const resolved = path.resolve(path.dirname(file), spec);
199
+ if (allFiles.has(resolved) && !keep.has(resolved))
200
+ queue.push(resolved);
201
+ }
202
+ }
203
+ let removed = 0;
204
+ for (const f of allFiles) {
205
+ if (!keep.has(f)) {
206
+ fs.rmSync(f);
207
+ removed++;
208
+ }
209
+ }
210
+ // Drop now-empty model dirs (never the DTO tree).
211
+ for (const entry of fs.readdirSync(generatedRoot, { withFileTypes: true })) {
212
+ if (!entry.isDirectory())
213
+ continue;
214
+ const p = path.join(generatedRoot, entry.name);
215
+ if (path.resolve(p) === dtoDir)
216
+ continue;
217
+ if (fs.existsSync(p) && fs.readdirSync(p).length === 0)
218
+ fs.rmdirSync(p);
219
+ }
220
+ console.log(`Pruned ${removed} unused GraphQL type file(s) from src/generated (read-only API).`);
221
+ }
222
+ exports.pruneGeneratedGraphql = pruneGeneratedGraphql;
@@ -24,6 +24,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
24
24
  */
25
25
  const utils_1 = require("./utils");
26
26
  const generate_dtos_1 = require("./generate-dtos");
27
+ const generate_all_1 = require("./generate-all");
27
28
  const generate_modules_1 = require("./generate-modules");
28
29
  const generate_services_1 = require("./generate-services");
29
30
  const generate_controllers_1 = require("./generate-controllers");
@@ -33,11 +34,10 @@ const generate_controllers_1 = require("./generate-controllers");
33
34
  // Node resolves as ESM. Only used on the interactive path.
34
35
  const dynamicImport = new Function('m', 'return import(m)');
35
36
  async function main() {
36
- // 1) Deploy-safe pass first — refresh client + GraphQL artifacts + DTO bases.
37
- console.log('Running Prisma Generate (client + GraphQL artifacts)...');
38
- // Project-local prisma: the CLI is usually global, so PATH has no project .bin.
39
- (0, utils_1.runProjectBin)('prisma', 'generate');
40
- (0, generate_dtos_1.generateDtoBases)((0, utils_1.loadAllModels)());
37
+ // 1) Deploy-safe pass first — refresh client + GraphQL artifacts + DTO bases
38
+ // (+ prune unused GraphQL types). Shared with `appx generate` so the two
39
+ // paths can't drift.
40
+ (0, generate_all_1.runDeploySafePass)();
41
41
  // 2) Candidates = non-framework models with no module yet. "Has a module"
42
42
  // means either the canonical folder exists OR some existing module already
43
43
  // serves the model via CoreController/CoreService<Model> — the latter
@@ -1 +1 @@
1
- {"version":3,"file":"generic.resolver.d.ts","sourceRoot":"","sources":["../../src/graphql/generic.resolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,IAAI,EAAC,MAAM,iBAAiB,CAAC;AAKrC;;;;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,CA+C/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;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"}
@@ -14,8 +14,28 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.CoreGraphqlResolver = void 0;
16
16
  const graphql_1 = require("@nestjs/graphql");
17
+ const common_1 = require("@nestjs/common");
17
18
  const plugins_1 = require("@paljs/plugins");
18
19
  const prisma_service_1 = require("../prisma/prisma.service");
20
+ /**
21
+ * Run a read and translate a Prisma **validation** error into a `400`. A
22
+ * malformed `where`/`orderBy` value (e.g. `createdAt: { gt: 2024 }` — a number
23
+ * where a DateTime is expected) can slip past GraphQL coercion and reach Prisma,
24
+ * which throws `PrismaClientValidationError`. That's bad client input, not a
25
+ * server fault: surface a clean `400` instead of a `500` that leaks the raw
26
+ * Prisma invocation. Genuine server errors are re-thrown unchanged.
27
+ */
28
+ async function readOrBadRequest(op) {
29
+ try {
30
+ return await op();
31
+ }
32
+ catch (e) {
33
+ if ((e?.name ?? e?.constructor?.name) === 'PrismaClientValidationError') {
34
+ throw new common_1.BadRequestException('Invalid query arguments (check where / orderBy types).');
35
+ }
36
+ throw e;
37
+ }
38
+ }
19
39
  /**
20
40
  * Build a **namespaced, read-only** GraphQL resolver for one model.
21
41
  *
@@ -68,24 +88,31 @@ function CoreGraphqlResolver(bundle) {
68
88
  }
69
89
  async find(args, info) {
70
90
  const select = new plugins_1.PrismaSelect(info).value;
71
- return this.prisma.getModelDelegate(modelName).findMany({ ...args, ...select });
91
+ return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).findMany({ ...args, ...select }));
72
92
  }
73
93
  async get(args, info) {
74
94
  const select = new plugins_1.PrismaSelect(info).value;
75
- return this.prisma.getModelDelegate(modelName).findFirst({ ...args, ...select });
95
+ return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).findFirst({ ...args, ...select }));
76
96
  }
77
97
  async count(where) {
78
- return this.prisma.getModelDelegate(modelName).count({ where });
98
+ return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).count({ where }));
79
99
  }
80
100
  };
81
101
  __decorate([
82
- (0, graphql_1.Query)(() => ModelQueries, { name: rootName }),
102
+ (0, graphql_1.Query)(() => ModelQueries, {
103
+ name: rootName,
104
+ description: `Read queries for ${modelName} — find / get / count. All results are ABAC-filtered.`,
105
+ }),
83
106
  __metadata("design:type", Function),
84
107
  __metadata("design:paramtypes", []),
85
108
  __metadata("design:returntype", ModelQueries)
86
109
  ], ModelQueriesResolver.prototype, "namespace", null);
87
110
  __decorate([
88
- (0, graphql_1.ResolveField)(() => [model], { name: 'find' }),
111
+ (0, graphql_1.ResolveField)(() => [model], {
112
+ name: 'find',
113
+ description: `List ${modelName} records the caller may read. Paginate with take / skip / cursor; ` +
114
+ `filter with where; sort with orderBy. You can only filter/sort by fields your role can read.`,
115
+ }),
89
116
  __param(0, (0, graphql_1.Args)({ type: () => findManyArgs, nullable: true })),
90
117
  __param(1, (0, graphql_1.Info)()),
91
118
  __metadata("design:type", Function),
@@ -93,7 +120,11 @@ function CoreGraphqlResolver(bundle) {
93
120
  __metadata("design:returntype", Promise)
94
121
  ], ModelQueriesResolver.prototype, "find", null);
95
122
  __decorate([
96
- (0, graphql_1.ResolveField)(() => model, { name: 'get', nullable: true }),
123
+ (0, graphql_1.ResolveField)(() => model, {
124
+ name: 'get',
125
+ nullable: true,
126
+ description: `The first ${modelName} matching the arguments, or null.`,
127
+ }),
97
128
  __param(0, (0, graphql_1.Args)({ type: () => findFirstArgs, nullable: true })),
98
129
  __param(1, (0, graphql_1.Info)()),
99
130
  __metadata("design:type", Function),
@@ -101,8 +132,15 @@ function CoreGraphqlResolver(bundle) {
101
132
  __metadata("design:returntype", Promise)
102
133
  ], ModelQueriesResolver.prototype, "get", null);
103
134
  __decorate([
104
- (0, graphql_1.ResolveField)(() => graphql_1.Int, { name: 'count' }),
105
- __param(0, (0, graphql_1.Args)('where', { type: () => whereInput, nullable: true })),
135
+ (0, graphql_1.ResolveField)(() => graphql_1.Int, {
136
+ name: 'count',
137
+ description: `Number of ${modelName} records matching where that the caller may read.`,
138
+ }),
139
+ __param(0, (0, graphql_1.Args)('where', {
140
+ type: () => whereInput,
141
+ nullable: true,
142
+ description: 'Filter. You can only filter by fields your role can read.',
143
+ })),
106
144
  __metadata("design:type", Function),
107
145
  __metadata("design:paramtypes", [Object]),
108
146
  __metadata("design:returntype", Promise)
@@ -113,6 +113,14 @@ export declare class PrismaService {
113
113
  private assertFilterFieldsReadable;
114
114
  /** Recursively assert no omitted field is referenced in a where/orderBy clause. */
115
115
  private assertClauseFieldsReadable;
116
+ /**
117
+ * True when a `where` predicate on a field checks only presence/nullness —
118
+ * `field: null`, `{ equals: null }`, `{ not: null }` — and therefore reveals
119
+ * whether the value is set but never the value itself. Anything with a
120
+ * non-null literal (`equals: "x"`, `contains`, `gt`, `in`, …) is not
121
+ * presence-only.
122
+ */
123
+ private isPresenceOnlyPredicate;
116
124
  private throwUnreadableFilterField;
117
125
  private applyWhereConditions;
118
126
  /**
@@ -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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"}
@@ -389,6 +389,13 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
389
389
  }
390
390
  // Direct reference to a field the role can't read.
391
391
  if (omit.has(key)) {
392
+ // A presence/nullness check (`field: null`, `{ equals: null }`,
393
+ // `{ not: null }`) reveals only whether the value is set — not the
394
+ // value — so it stays allowed on a `where` (the `hasX` / `isDeleted`
395
+ // pattern, without needing BYPASS_OMISSION). Any value-bearing
396
+ // operator, and every `orderBy`/`distinct`, still leaks and is rejected.
397
+ if (kind === 'where' && this.isPresenceOnlyPredicate(value))
398
+ continue;
392
399
  this.throwUnreadableFilterField(modelName, key, userRole, kind);
393
400
  }
394
401
  // Relation — recurse into the related model (unwrapping list/one filters).
@@ -408,6 +415,23 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
408
415
  // (where) or 'asc'/'desc' (orderBy) — not a field map, so no recursion.
409
416
  }
410
417
  }
418
+ /**
419
+ * True when a `where` predicate on a field checks only presence/nullness —
420
+ * `field: null`, `{ equals: null }`, `{ not: null }` — and therefore reveals
421
+ * whether the value is set but never the value itself. Anything with a
422
+ * non-null literal (`equals: "x"`, `contains`, `gt`, `in`, …) is not
423
+ * presence-only.
424
+ */
425
+ isPresenceOnlyPredicate(value) {
426
+ if (value === null)
427
+ return true; // `field: null`
428
+ if (typeof value !== 'object' || Array.isArray(value))
429
+ return false; // a literal value
430
+ const keys = Object.keys(value);
431
+ if (keys.length === 0)
432
+ return false;
433
+ return keys.every((k) => (k === 'equals' || k === 'not') && value[k] === null);
434
+ }
411
435
  throwUnreadableFilterField(modelName, field, userRole, kind) {
412
436
  throw new common_1.HttpException(`Cannot ${kind === 'orderBy' ? 'order by' : 'filter by'} '${modelName}.${field}' — not readable by role ${userRole}.`, common_1.HttpStatus.FORBIDDEN);
413
437
  }
package/docs/graphql.md CHANGED
@@ -88,22 +88,62 @@ query {
88
88
 
89
89
  Each selected field resolves independently through the proxy (its own authorized query), so multiplexing never weakens enforcement. `count` is filtered too — it counts only the rows the caller may read, never the table total.
90
90
 
91
- ### Nested relations & children
91
+ ### Nested relations
92
92
 
93
- Select related models inline, to any depth. The GraphQL selection is compiled into a single Prisma `select`, so relations come back in the same authorized query (no N+1):
93
+ Select **to-one** relations inline — they return a single record, and the selection compiles into one Prisma `select` (no N+1):
94
94
 
95
95
  ```graphql
96
96
  query {
97
97
  album {
98
- find {
98
+ get(where: { id: { equals: 1 } }) {
99
99
  title
100
- tracks { title } # to-many child
101
- artist { name } # to-one relation
100
+ artist { name } # to-one relation — OK
102
101
  }
103
102
  }
104
103
  }
105
104
  ```
106
105
 
106
+ **To-many relations are not nested-selectable** (`album.tracks`, `project.tasks`). Nested lists have no pagination, so an inline list could return unbounded rows. Query the list at the **top level** instead, where pagination applies:
107
+
108
+ ```graphql
109
+ query {
110
+ album { get(where: { id: { equals: 1 } }) { title } }
111
+ tracks: track { find(where: { albumId: { equals: 1 } }, take: 20, orderBy: { id: asc }) { id title } }
112
+ }
113
+ ```
114
+
115
+ You can still get a bounded **count** of a to-many relation via `_count` (`album { get { _count { tracks } } }`), and you can **filter** by a to-many relation in `where` (`some` / `every` / `none`) — that returns the parent rows, not the list.
116
+
117
+ ---
118
+
119
+ ## Filtering, sorting & pagination
120
+
121
+ `find` / `get` take the generated Prisma-style arguments; `count` takes `where`.
122
+
123
+ - **Pagination** (`find`): `take`, `skip`, `cursor: { <uniqueField>: … }`, `distinct: [field]`.
124
+ - **Sorting** (`find`): `orderBy: { field: asc | desc }` — including nested to-one, `orderBy: { artist: { name: asc } }`.
125
+
126
+ **`where` operators**, by field type:
127
+
128
+ | Field type | Operators |
129
+ |---|---|
130
+ | `String` | `equals`, `not`, `in`, `notIn`, `lt`, `lte`, `gt`, `gte`, `contains`, `startsWith`, `endsWith`, `mode: insensitive` |
131
+ | `Int` / `Float` / `Decimal` / `BigInt` | `equals`, `not`, `in`, `notIn`, `lt`, `lte`, `gt`, `gte` |
132
+ | `Boolean` | `equals`, `not` |
133
+ | `DateTime` | `equals`, `not`, `in`, `notIn`, `lt`, `lte`, `gt`, `gte` |
134
+ | enum | `equals`, `not`, `in`, `notIn` |
135
+ | nullable field | any of the above, plus `null` / `{ equals: null }` / `{ not: null }` |
136
+ | to-one relation | `is: { … }`, `isNot: { … }` (a nested `where`) |
137
+ | to-many relation | `some: { … }`, `every: { … }`, `none: { … }` |
138
+ | logical | `AND`, `OR`, `NOT` |
139
+
140
+ The **generated GraphQL input type is the authoritative allowlist** — an operator that isn't on a field's filter type is a GraphQL error, so unknown operators are rejected before they run. Browse the exact set per field in the Apollo Sandbox (`GET /graphql`).
141
+
142
+ Two framework rules on top of that:
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.
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
+
107
147
  ---
108
148
 
109
149
  ## What the caller can and cannot see (ABAC)
@@ -113,7 +153,7 @@ All three operations route through `PrismaService.getModelDelegate`, so the rule
113
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.
114
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.)
115
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.
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.
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.
117
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.
118
158
 
119
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.128-beta.1",
3
+ "version": "0.1.128-beta.2",
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",