@appxdigital/appx-core 0.1.121-beta.4 → 0.1.121-beta.5
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/README.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
<img src="./public/appx-core-logo.png" alt="AppX Core Logo" width="220" />
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
+
[](https://github.com/appxdigital/appx-core/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/appxdigital/appx-core/actions/workflows/release.yml)
|
|
9
|
+
[](https://github.com/appxdigital/appx-core/actions/workflows/release-cli.yml)
|
|
10
|
+
|
|
7
11
|
**AppX Core** is a production-oriented backend foundation that accelerates the delivery of secure, data-access-controlled APIs with an integrated admin backoffice. It scaffolds a native **NestJS** application and extends it with opinionated building blocks for authentication, authorization (ABAC-first), CRUD acceleration, and administration through **AdminJS**.
|
|
8
12
|
|
|
9
13
|
Built and maintained by **AppX** (appx-digital.com).
|
|
@@ -33,6 +37,7 @@ Built and maintained by **AppX** (appx-digital.com).
|
|
|
33
37
|
- [Permissions](#permissions)
|
|
34
38
|
- [Permissions Configuration Shape](#permissions-configuration-shape)
|
|
35
39
|
- [Rules](#rules)
|
|
40
|
+
- [Reading Related Models](#reading-related-models)
|
|
36
41
|
- [Action Aliases and Fallbacks](#action-aliases-and-fallbacks)
|
|
37
42
|
- [Custom Controller Actions](#custom-controller-actions)
|
|
38
43
|
- [Temporarily Exposing Models](#temporarily-exposing-models)
|
|
@@ -110,6 +115,21 @@ AppX Core is designed as a **base layer** on top of NestJS:
|
|
|
110
115
|
npm install -g @appxdigital/appx-core-cli
|
|
111
116
|
```
|
|
112
117
|
|
|
118
|
+
**Release channels.** AppX Core publishes both packages on two npm dist-tags:
|
|
119
|
+
|
|
120
|
+
- **`latest`** — stable releases. This is what the commands above install.
|
|
121
|
+
- **`beta`** — prereleases, for trying upcoming changes before they're promoted. Opt in explicitly with the `@beta` tag (a caret range such as `^0.1.x` never resolves to a prerelease on its own):
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm install -g @appxdigital/appx-core-cli@beta # CLI prerelease
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`appx-core create` scaffolds a project that depends on the **stable** library. To test a library prerelease in that project, install it there:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm install @appxdigital/appx-core@beta # inside your project
|
|
131
|
+
```
|
|
132
|
+
|
|
113
133
|
### 2) Create a New Project
|
|
114
134
|
|
|
115
135
|
```bash
|
|
@@ -119,10 +139,13 @@ cd <your-project>
|
|
|
119
139
|
|
|
120
140
|
### 3) Configure Environment
|
|
121
141
|
|
|
122
|
-
|
|
142
|
+
`appx-core create` **already writes a `.env`** at the project root, with secure
|
|
143
|
+
random secrets generated for you. You don't need to create it by hand — just
|
|
144
|
+
open it and set your database connection (and `CORS_ORIGIN` for production). The
|
|
145
|
+
generated file looks like this:
|
|
123
146
|
|
|
124
147
|
```bash
|
|
125
|
-
##
|
|
148
|
+
## Database ##
|
|
126
149
|
DB_HOST=127.0.0.1
|
|
127
150
|
DB_PORT=3306
|
|
128
151
|
DB_USER=root
|
|
@@ -131,22 +154,36 @@ DB_NAME=generic
|
|
|
131
154
|
DB_PROVIDER=mysql
|
|
132
155
|
DB_URL="${DB_PROVIDER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
|
133
156
|
|
|
134
|
-
##
|
|
157
|
+
## App ##
|
|
135
158
|
APP_PORT=3000
|
|
136
159
|
USE_TRANSACTION=true
|
|
160
|
+
CORS_ORIGIN=http://localhost:3000 # your front-end origin; set to your real domain in production
|
|
137
161
|
|
|
138
162
|
## Sessions ##
|
|
139
|
-
SESSION_SECRET
|
|
140
|
-
SESSION_COOKIE_NAME=
|
|
163
|
+
SESSION_SECRET=<64-char random hex — generated for you>
|
|
164
|
+
SESSION_COOKIE_NAME=session_nestjs-project
|
|
141
165
|
SESSION_TTL=86400
|
|
142
166
|
|
|
143
167
|
## JWT ##
|
|
144
168
|
JWT_EXPIRES_IN=10d
|
|
145
169
|
JWT_REFRESH_EXPIRES_IN=1y
|
|
146
|
-
JWT_SECRET
|
|
147
|
-
JWT_REFRESH_SECRET
|
|
170
|
+
JWT_SECRET=<random — generated for you>
|
|
171
|
+
JWT_REFRESH_SECRET=<random — generated for you>
|
|
148
172
|
```
|
|
149
173
|
|
|
174
|
+
> **Keep the generated secrets — don't replace them with placeholders like `change-me`.**
|
|
175
|
+
> `SESSION_SECRET` must be **at least 32 characters** or the app throws at
|
|
176
|
+
> startup (`buildCoreSessionOptions` — there is no insecure fallback).
|
|
177
|
+
>
|
|
178
|
+
> **`NODE_ENV`** must be set in the runtime environment. The `start` scripts set
|
|
179
|
+
> it to `development`; set it explicitly in production. Env files resolve as
|
|
180
|
+
> `.env.${NODE_ENV}` first, then `.env` (via `coreEnvFilePath()`), so a plain
|
|
181
|
+
> `.env` works for development and you can add `.env.production` for prod.
|
|
182
|
+
|
|
183
|
+
Baseline HTTP hardening (a whitelisting `ValidationPipe`, CORS, and Helmet) is
|
|
184
|
+
applied for you by `setupCoreSecurity(app, …)` in `src/main.ts`; `helmet` is a
|
|
185
|
+
peer dependency the scaffold already includes.
|
|
186
|
+
|
|
150
187
|
### 4) Define Data Models
|
|
151
188
|
|
|
152
189
|
Edit:
|
|
@@ -288,6 +325,32 @@ deleteMany: {
|
|
|
288
325
|
},
|
|
289
326
|
```
|
|
290
327
|
|
|
328
|
+
### Reading Related Models
|
|
329
|
+
|
|
330
|
+
When a query pulls in a relation (`include: { author: true }`, or the equivalent `select`), that related model's **own** read permissions are enforced too — a relation is never a way around ABAC. How this plays out depends on the relation's cardinality.
|
|
331
|
+
|
|
332
|
+
**To-one relations (`belongsTo`, 1:1)** are filtered with *inner-join* semantics: the related model's read `conditions` are applied to the **parent** row.
|
|
333
|
+
|
|
334
|
+
- If the related model has **no** read rule for the caller's role, including the relation raises a `403` (`Missing permissions on model X`). Grant a read rule on every model you expose through an `include`.
|
|
335
|
+
- If the related model has a **conditional** read rule and the specific related row does not satisfy it, the **whole parent row is dropped** from the result (`findFirst` returns `null`; `findMany` omits it) — the parent is *not* returned with the relation set to `null`. Put plainly: you cannot see a row whose related record you are not allowed to see.
|
|
336
|
+
|
|
337
|
+
**To-many relations (`hasMany`, many-to-many)** are filtered in place: the parent row is kept and the child list is narrowed to the rows the caller may read (which can be empty).
|
|
338
|
+
|
|
339
|
+
Example — `Comment.author` points at `User`, and a `User` is only self-readable:
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
User: {
|
|
343
|
+
USER: { findMany: { conditions: { id: PermissionPlaceholder.USER_ID } } },
|
|
344
|
+
},
|
|
345
|
+
Comment: {
|
|
346
|
+
USER: { findMany: { conditions: { authorId: PermissionPlaceholder.USER_ID } } },
|
|
347
|
+
},
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`comment.findFirst({ where: { id }, include: { author: true } })` returns the comment only when the caller is also its author (a `User` row they are allowed to read); otherwise the comment itself is filtered out. This is intentional and fails closed.
|
|
351
|
+
|
|
352
|
+
> **Why not return the parent with the relation nulled?** Prisma does not allow a `where` filter on a to-one relation inside `include` / `select`, so the condition is applied to the parent instead. Masking the relation while keeping the parent would require an extra query per included relation; the framework favors the single-query, fail-closed behavior.
|
|
353
|
+
|
|
291
354
|
### Action Aliases and Fallbacks
|
|
292
355
|
|
|
293
356
|
To avoid duplicated config, the permissions engine supports fallback behavior. In practice:
|
|
@@ -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,EAAC,MAAM,yCAAyC,CAAC;AAE9E,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;AAGF,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,QAAQ,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,YAAY,GAAG,mBAAmB,GAAG,QAAQ,GAAG,QAAQ,CAAC,CAAC;AAEtI,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;IAO3F,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;IAwEX;;;;OAIG;IACH,gBAAgB,CAAC,CAAC,SAAS,QAAQ,EAAE,KAAK,EAAE,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC;IAYlE,IAAI,KAAK,IAAI,aAAa,CAUzB;IAED,IAAI,IAAI,IAAI,eAAe,CAAC,MAAM,CAAC,CAElC;IAED,IAAI,OAAO,IAAI,eAAe,CAAC,SAAS,CAAC,CAExC;IAED,IAAI,gBAAgB,QAEnB;IAED;;;OAGG;IACH,WAAW;IAqDX;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;
|
|
1
|
+
{"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -226,6 +226,37 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
|
|
|
226
226
|
omitFields.forEach((field) => {
|
|
227
227
|
delete args.select[field];
|
|
228
228
|
});
|
|
229
|
+
// Omission must also apply to nested relation selections. Without this,
|
|
230
|
+
// a caller reads an omitted field on a RELATED model by nesting the
|
|
231
|
+
// request (e.g. `select: { rel: { select: { secret: true } } }`) — the
|
|
232
|
+
// top-level delete above only covers this model's own fields.
|
|
233
|
+
for (const key of Object.keys(args.select)) {
|
|
234
|
+
const relation = this.getRelation(modelName, key);
|
|
235
|
+
if (!relation) {
|
|
236
|
+
continue; // scalar field (`true`) or `_count` — nothing to recurse
|
|
237
|
+
}
|
|
238
|
+
const relatedOmit = this.getFieldsToOmit(relation.model, userRole);
|
|
239
|
+
const value = args.select[key];
|
|
240
|
+
if (value === true) {
|
|
241
|
+
// `rel: true` returns every scalar of the related model — narrow
|
|
242
|
+
// it to the readable ones.
|
|
243
|
+
args.select[key] = {
|
|
244
|
+
select: this.buildSelectFields(relation.model, relatedOmit, null, null, userRole),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
else if (value && typeof value === 'object') {
|
|
248
|
+
if (value.select || value.include) {
|
|
249
|
+
// Recurse — strips the related model's omitted fields (and
|
|
250
|
+
// deeper) while preserving any where/orderBy/take on `value`.
|
|
251
|
+
args.select[key] = this.applyFieldOmission(relation.model, userRole, value);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
// Relation args with no explicit projection (e.g. `{ where }`)
|
|
255
|
+
// — inject a select that excludes omitted fields.
|
|
256
|
+
value.select = this.buildSelectFields(relation.model, relatedOmit, null, null, userRole);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
229
260
|
}
|
|
230
261
|
else if (args.include) {
|
|
231
262
|
this.debug(`Found included model '${modelName}', generating select fields`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appxdigital/appx-core",
|
|
3
|
-
"version": "0.1.121-beta.
|
|
3
|
+
"version": "0.1.121-beta.5",
|
|
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",
|