@appxdigital/appx-core 0.1.128-beta.1 → 0.1.128-beta.3
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 +14 -2
- package/dist/config/generate-all.d.ts +7 -1
- package/dist/config/generate-all.d.ts.map +1 -1
- package/dist/config/generate-all.js +28 -9
- package/dist/config/generate-graphql.d.ts +33 -0
- package/dist/config/generate-graphql.d.ts.map +1 -1
- package/dist/config/generate-graphql.js +137 -1
- package/dist/config/generate-models.js +5 -5
- package/dist/graphql/generic.resolver.d.ts.map +1 -1
- package/dist/graphql/generic.resolver.js +46 -8
- package/dist/prisma/prisma.service.d.ts +8 -0
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +33 -1
- package/docs/graphql.md +46 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -24,7 +24,15 @@ 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.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **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`.
|
|
28
36
|
|
|
29
37
|
### Changed / Removed
|
|
30
38
|
|
|
@@ -41,7 +49,11 @@ Read-only GraphQL, reworked into a **namespaced, opt-in-per-module** API.
|
|
|
41
49
|
3. Add **`GraphqlModule`** to your `app.module` imports (new projects already have it): `import { GraphqlModule } from '@appxdigital/appx-core';`.
|
|
42
50
|
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
51
|
5. **Update clients:** `findAllProjects(...)` → `project { find(...) }`, `findFirstProject` / `findOneProject` → `project { get(...) }`. Mutations remain REST-only.
|
|
44
|
-
6. **
|
|
52
|
+
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.
|
|
53
|
+
- **Presence checks still work:** `{ field: null }` / `{ NOT: { field: null } }` (e.g. `hasPassword`, `isDeleted`) are allowed — they don't reveal the value.
|
|
54
|
+
- **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.
|
|
55
|
+
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.
|
|
56
|
+
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
57
|
|
|
46
58
|
---
|
|
47
59
|
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
(
|
|
29
|
-
|
|
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
|
-
|
|
38
|
-
//
|
|
39
|
-
(0,
|
|
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":"
|
|
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, {
|
|
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], {
|
|
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, {
|
|
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, {
|
|
105
|
-
|
|
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":";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;
|
|
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
|
-
|
|
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];
|
|
@@ -389,6 +397,13 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
389
397
|
}
|
|
390
398
|
// Direct reference to a field the role can't read.
|
|
391
399
|
if (omit.has(key)) {
|
|
400
|
+
// A presence/nullness check (`field: null`, `{ equals: null }`,
|
|
401
|
+
// `{ not: null }`) reveals only whether the value is set — not the
|
|
402
|
+
// value — so it stays allowed on a `where` (the `hasX` / `isDeleted`
|
|
403
|
+
// pattern, without needing BYPASS_OMISSION). Any value-bearing
|
|
404
|
+
// operator, and every `orderBy`/`distinct`, still leaks and is rejected.
|
|
405
|
+
if (kind === 'where' && this.isPresenceOnlyPredicate(value))
|
|
406
|
+
continue;
|
|
392
407
|
this.throwUnreadableFilterField(modelName, key, userRole, kind);
|
|
393
408
|
}
|
|
394
409
|
// Relation — recurse into the related model (unwrapping list/one filters).
|
|
@@ -408,6 +423,23 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
408
423
|
// (where) or 'asc'/'desc' (orderBy) — not a field map, so no recursion.
|
|
409
424
|
}
|
|
410
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* True when a `where` predicate on a field checks only presence/nullness —
|
|
428
|
+
* `field: null`, `{ equals: null }`, `{ not: null }` — and therefore reveals
|
|
429
|
+
* whether the value is set but never the value itself. Anything with a
|
|
430
|
+
* non-null literal (`equals: "x"`, `contains`, `gt`, `in`, …) is not
|
|
431
|
+
* presence-only.
|
|
432
|
+
*/
|
|
433
|
+
isPresenceOnlyPredicate(value) {
|
|
434
|
+
if (value === null)
|
|
435
|
+
return true; // `field: null`
|
|
436
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
437
|
+
return false; // a literal value
|
|
438
|
+
const keys = Object.keys(value);
|
|
439
|
+
if (keys.length === 0)
|
|
440
|
+
return false;
|
|
441
|
+
return keys.every((k) => (k === 'equals' || k === 'not') && value[k] === null);
|
|
442
|
+
}
|
|
411
443
|
throwUnreadableFilterField(modelName, field, userRole, kind) {
|
|
412
444
|
throw new common_1.HttpException(`Cannot ${kind === 'orderBy' ? 'order by' : 'filter by'} '${modelName}.${field}' — not readable by role ${userRole}.`, common_1.HttpStatus.FORBIDDEN);
|
|
413
445
|
}
|
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
|
|
91
|
+
### Nested relations
|
|
92
92
|
|
|
93
|
-
Select
|
|
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
|
-
|
|
98
|
+
get(where: { id: { equals: 1 } }) {
|
|
99
99
|
title
|
|
100
|
-
|
|
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`. 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
|
+
- **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
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.1.128-beta.3",
|
|
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",
|