@appxdigital/appx-core 0.1.128-beta.0 → 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 +16 -7
- package/README.md +6 -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 +149 -3
- package/dist/config/generate-models.js +5 -5
- package/dist/config/generate-modules.d.ts.map +1 -1
- package/dist/config/generate-modules.js +3 -4
- 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 +20 -0
- package/dist/prisma/prisma.service.d.ts.map +1 -1
- package/dist/prisma/prisma.service.js +93 -0
- package/docs/graphql.md +51 -11
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -21,26 +21,35 @@ 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 `
|
|
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 (
|
|
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 (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.
|
|
27
32
|
|
|
28
33
|
### Changed / Removed
|
|
29
34
|
|
|
30
|
-
- The old root-level `findAll<Model>s` / `findOne<Model>` / `findFirst<Model>` queries and the `GenericResolverFactory` export are **removed**, replaced by `CoreGraphqlResolver
|
|
35
|
+
- 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
36
|
- `appx-core generate models` **no longer scaffolds a per-module `*.resolver.ts` file**; GraphQL is opt-in via the one-liner above.
|
|
37
|
+
- **`@apollo/server` is now a declared peer dependency** (`GraphqlModule` mounts Apollo by default). A normal `npm install` pulls it — no manual install.
|
|
32
38
|
|
|
33
39
|
### Migration
|
|
34
40
|
|
|
35
41
|
> **Behaviour change — the GraphQL resolver API changed and per-module resolver files are no longer generated.** Only relevant if you use GraphQL.
|
|
36
42
|
|
|
37
43
|
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.
|
|
44
|
+
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
45
|
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:
|
|
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).
|
|
41
47
|
5. **Update clients:** `findAllProjects(...)` → `project { find(...) }`, `findFirstProject` / `findOneProject` → `project { get(...) }`. Mutations remain REST-only.
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
44
53
|
|
|
45
54
|
---
|
|
46
55
|
|
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
|
|
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,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":"
|
|
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");
|
|
@@ -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 {
|
|
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
|
/**
|
|
@@ -74,3 +84,139 @@ function generateGraphqlBundles(allModels) {
|
|
|
74
84
|
}
|
|
75
85
|
}
|
|
76
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":"generate-modules.d.ts","sourceRoot":"","sources":["../../src/config/generate-modules.ts"],"names":[],"mappings":"
|
|
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
|
|
46
|
-
// import {
|
|
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
|
-
//
|
|
53
|
+
// ${model}Resolver,
|
|
55
54
|
],
|
|
56
55
|
})
|
|
57
56
|
export class ${model}Module {}
|
|
@@ -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)
|
|
@@ -102,6 +102,26 @@ 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
|
+
/**
|
|
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;
|
|
124
|
+
private throwUnreadableFilterField;
|
|
105
125
|
private applyWhereConditions;
|
|
106
126
|
/**
|
|
107
127
|
* 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":";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;
|
|
1
|
+
{"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -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,94 @@ 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
|
+
// 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;
|
|
399
|
+
this.throwUnreadableFilterField(modelName, key, userRole, kind);
|
|
400
|
+
}
|
|
401
|
+
// Relation — recurse into the related model (unwrapping list/one filters).
|
|
402
|
+
const relation = this.getRelation(modelName, key);
|
|
403
|
+
if (relation) {
|
|
404
|
+
const wrappers = ['some', 'every', 'none', 'is', 'isNot'];
|
|
405
|
+
const wrapped = value && typeof value === 'object' ? wrappers.filter((w) => w in value) : [];
|
|
406
|
+
if (wrapped.length) {
|
|
407
|
+
for (const w of wrapped)
|
|
408
|
+
this.assertClauseFieldsReadable(relation.model, userRole, value[w], kind);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
this.assertClauseFieldsReadable(relation.model, userRole, value, kind);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Otherwise: a readable scalar field. Its value is an operator object
|
|
415
|
+
// (where) or 'asc'/'desc' (orderBy) — not a field map, so no recursion.
|
|
416
|
+
}
|
|
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
|
+
}
|
|
435
|
+
throwUnreadableFilterField(modelName, field, userRole, kind) {
|
|
436
|
+
throw new common_1.HttpException(`Cannot ${kind === 'orderBy' ? 'order by' : 'filter by'} '${modelName}.${field}' — not readable by role ${userRole}.`, common_1.HttpStatus.FORBIDDEN);
|
|
437
|
+
}
|
|
345
438
|
applyWhereConditions(modelName, userRole, args, user, action) {
|
|
346
439
|
if (!args) {
|
|
347
440
|
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 {
|
|
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
|
-
|
|
42
|
+
AlbumResolver, // ← add = exposed; remove = not exposed
|
|
42
43
|
],
|
|
43
44
|
})
|
|
44
45
|
export class AlbumModule {}
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
`
|
|
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
|
|
|
@@ -87,22 +88,62 @@ query {
|
|
|
87
88
|
|
|
88
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.
|
|
89
90
|
|
|
90
|
-
### Nested relations
|
|
91
|
+
### Nested relations
|
|
91
92
|
|
|
92
|
-
Select
|
|
93
|
+
Select **to-one** relations inline — they return a single record, and the selection compiles into one Prisma `select` (no N+1):
|
|
93
94
|
|
|
94
95
|
```graphql
|
|
95
96
|
query {
|
|
96
97
|
album {
|
|
97
|
-
|
|
98
|
+
get(where: { id: { equals: 1 } }) {
|
|
98
99
|
title
|
|
99
|
-
|
|
100
|
-
artist { name } # to-one relation
|
|
100
|
+
artist { name } # to-one relation — OK
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
```
|
|
105
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
|
+
|
|
106
147
|
---
|
|
107
148
|
|
|
108
149
|
## What the caller can and cannot see (ABAC)
|
|
@@ -112,6 +153,7 @@ All three operations route through `PrismaService.getModelDelegate`, so the rule
|
|
|
112
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.
|
|
113
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.)
|
|
114
155
|
- **Inaccessible nested records are filtered.** If a nested relation points at a record the caller can't read, that relation is filtered out — a to-many yields only the visible rows; an inaccessible to-one drops the parent row. You can hold access to a record while a record it links to is withheld.
|
|
156
|
+
- **You can only filter/sort by fields you can read.** A `where` / `orderBy` / `distinct` that references a field the role can't read (`@Role`) is rejected with `403` — top level or nested — so a hidden column can't be used to filter or order either. The one exception is a **presence/nullness** check in `where`: `{ field: null }` / `{ NOT: { field: null } }` is allowed (it reveals only whether a value is set, not the value), so `hasX` / `isDeleted` patterns work without a bypass.
|
|
115
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.
|
|
116
158
|
|
|
117
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.
|
|
@@ -125,8 +167,6 @@ Always let the generated resolver serve the data — a custom resolver that reac
|
|
|
125
167
|
- **Mutations** (create/update/delete) — use REST.
|
|
126
168
|
- **Full `aggregate`** (sum/avg/min/max/groupBy) — only `count` is exposed today.
|
|
127
169
|
|
|
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
170
|
---
|
|
131
171
|
|
|
132
172
|
## 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.
|
|
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",
|
|
@@ -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",
|