@appxdigital/appx-core 0.1.128 → 0.1.130

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +22 -19
  3. package/dist/backoffice/appx-core-admin.module.d.ts.map +1 -1
  4. package/dist/backoffice/appx-core-admin.module.js +2 -3
  5. package/dist/common/config/permissionsConfigTypes.d.ts +3 -2
  6. package/dist/common/config/permissionsConfigTypes.d.ts.map +1 -1
  7. package/dist/common/guards/rbac.guard.d.ts.map +1 -1
  8. package/dist/common/guards/rbac.guard.js +6 -0
  9. package/dist/common/utils/schema-annotations.util.d.ts +7 -0
  10. package/dist/common/utils/schema-annotations.util.d.ts.map +1 -0
  11. package/dist/common/utils/schema-annotations.util.js +12 -0
  12. package/dist/config/generate-dtos.d.ts +1 -0
  13. package/dist/config/generate-dtos.d.ts.map +1 -1
  14. package/dist/config/generate-dtos.js +5 -2
  15. package/dist/modules/auth/refresh-token.strategy.d.ts +1 -3
  16. package/dist/modules/auth/refresh-token.strategy.d.ts.map +1 -1
  17. package/dist/modules/auth/refresh-token.strategy.js +9 -6
  18. package/dist/modules/core/core.controller.d.ts.map +1 -1
  19. package/dist/modules/core/core.controller.js +2 -13
  20. package/dist/prisma/permissions-validator.d.ts +1 -1
  21. package/dist/prisma/permissions-validator.d.ts.map +1 -1
  22. package/dist/prisma/permissions-validator.js +37 -11
  23. package/dist/prisma/prisma.service.d.ts +69 -0
  24. package/dist/prisma/prisma.service.d.ts.map +1 -1
  25. package/dist/prisma/prisma.service.js +322 -57
  26. package/docs/README.md +3 -1
  27. package/docs/data-access.md +21 -5
  28. package/docs/dtos.md +2 -2
  29. package/docs/generate.md +2 -2
  30. package/docs/graphql.md +2 -3
  31. package/docs/limitations.md +8 -9
  32. package/docs/permissions.md +14 -5
  33. package/docs/storage.md +89 -0
  34. package/docs/testing.md +74 -0
  35. package/package.json +2 -2
@@ -46,8 +46,8 @@ The proxy can only enforce ABAC on methods that accept a `where` it can inject c
46
46
  **Consequences:**
47
47
 
48
48
  - `update()` / `delete()` return `{ count }`, **not** the affected record. Re-read if you need the row.
49
- - These operate on **all matching rows**, not one — always pass a `where`. With ABAC conditions injected, a cross-owner `update({ where: { id } })` simply affects `{ count: 0 }` rather than erroring.
50
- - The TypeScript types reflect this: calling `update()` with a lone unique `where` and expecting a record back is a **compile error**, not a runtime surprise.
49
+ - These operate on **all matching rows**, not one — always pass a `where`. With ABAC conditions injected, a cross-owner `update({ where: { id } })` affects `{ count: 0 }` rather than erroring.
50
+ - The TypeScript types reflect this: calling `update()` with a lone unique `where` and expecting a record back is a **compile error**.
51
51
 
52
52
  Use `findMany` / `findFirst` / `findFirstOrThrow` / `updateMany` / `deleteMany` directly if you prefer the explicit names.
53
53
 
@@ -68,6 +68,22 @@ A column annotated `/// @Role(ADMIN)` is returned only to roles in the list; for
68
68
  - If the related model has **no** read rule for the role, including it throws `403`. Grant a read rule on every model you expose through an `include`.
69
69
  - If the related row exists but doesn't satisfy the rule, the **whole parent row is dropped** (`findFirst` → `null`, `findMany` → the row is omitted). The parent is **not** returned with the relation set to `null`. In short: you can't see a row whose related record you're not allowed to see. This is intentional and fails closed.
70
70
  - **To-many relations are filtered in place**: the parent is kept, and the child list is narrowed to the rows the caller may read (possibly empty).
71
+ - **Both rules apply at every depth**, including below a to-one relation: in `include: { project: { include: { owner: true, tasks: true } } }`, `owner` gates the row like `project` does, and `tasks` is narrowed.
72
+ - **`_count` counts readable rows only.** `select: { _count: { select: { tasks: true } } }` returns how many related tasks the caller may read. Counting a relation whose model has no read rule for the role throws `403`.
73
+
74
+ ### Filtering and ordering by a relation
75
+
76
+ A client filter or sort that reaches into a related model only takes into account the related rows the caller can read:
77
+
78
+ ```ts
79
+ // Projects with a task assigned to alice — evaluated over the tasks the caller can read.
80
+ prisma.model.project.findMany({ where: { tasks: { some: { assignee: { email: 'alice@x.io' } } } } });
81
+ ```
82
+
83
+ - `some` / `none` / `is` / `isNot` match readable related rows only; `every` must hold for every readable related row. The same applies to the `where` of a nested selection and of a `_count` entry, at any depth.
84
+ - Filtering through a relation whose model has **no** read rule for the role throws `403` (`Cannot filter by 'project.tasks' …`). Give the role a read rule, or expose the model for that request.
85
+ - **Ordering by a related to-one field** (`orderBy: { assignee: { name: 'asc' } }`) requires that record to be readable, like including it: rows whose related record isn't readable are left out (a null relation is kept). A related model with no read rule throws `403`.
86
+ - **Ordering by a to-many count** (`orderBy: { tasks: { _count: 'desc' } }`) requires the related model to be readable without conditions (`'ALL'` or exposed); otherwise it throws `403`.
71
87
 
72
88
  ---
73
89
 
@@ -75,8 +91,8 @@ A column annotated `/// @Role(ADMIN)` is returned only to roles in the list; for
75
91
 
76
92
  Use sparingly and deliberately; each disables part of the protection.
77
93
 
78
- - **Per-call options** — `prisma.<accessor>.<method>(args, { BYPASS_FILTERING, BYPASS_OMISSION })`. `BYPASS_FILTERING` skips condition injection (and create-enforcement); `BYPASS_OMISSION` skips field omission. Available on the typed accessors (`prisma.user`, `prisma.session`, `prisma.userRefreshToken`).
79
- - **`@ExposeModels(...)` / `@Permission(action, [models])`** — expose models for a request so they skip row/field filtering, scoped to the handler. See [permissions.md](./permissions.md).
94
+ - **Per-call options** — `prisma.<accessor>.<method>(args, { BYPASS_FILTERING, BYPASS_OMISSION })`. `BYPASS_FILTERING` skips condition injection (and create-enforcement); `BYPASS_OMISSION` skips field omission (relations are still row-filtered). Accepted as the second argument of every proxied model method, but not declared in the method types.
95
+ - **`@ExposeModels(...)` / `@Permission(action, [models])`** — expose models for a request so they skip row filtering, scoped to the handler. Field omission (`@Role`) still applies; combine with `BYPASS_OMISSION` to read hidden columns. See [permissions.md](./permissions.md).
80
96
  - **`prismaService.withExposedModels(models, async () => { ... })`** — service-level scoped exposure for a block of code (e.g. registration pre-checks). Model names are lowercased/case-insensitive.
81
97
 
82
98
  Framework flows that must write without a permission (registration, sessions, tokens) use `BYPASS_FILTERING`.
@@ -89,7 +105,7 @@ Framework flows that must write without a permission (registration, sessions, to
89
105
 
90
106
  - Own-scalar conditions are checked against `data`; `belongsTo` relation conditions are checked by looking up the referenced parent.
91
107
  - A model/role with **no** `create` permission is **default-denied** (`403`).
92
- - It **fails closed**: unsupported condition shapes for create (list-relation `some`/`every`/`none`, exotic operators) throw rather than silently allow. For those, use `setUserIdField` to set an owner id server-side and validate inputs in DTOs.
108
+ - It **fails closed**: unsupported condition shapes for create (list-relation `some`/`every`/`none`, exotic operators) throw rather than silently allow. For those, use `set` on the rule to write an owner id server-side and validate inputs in DTOs.
93
109
  - In development only, a warning fires if a just-created row wouldn't satisfy the model's *read* rule (i.e. the creator couldn't read it back).
94
110
 
95
111
  ---
package/docs/dtos.md CHANGED
@@ -95,7 +95,7 @@ model Task {
95
95
 
96
96
  There is **no per-role field strip** — a field is either writable through the generated DTO or it isn't. If one role may write a field and another may not (e.g. only `ADMIN` sets `role`), the field is a **privileged** one: mark it `/// @NoWrite` so generic CRUD never accepts it from anyone, and set it out-of-band — a dedicated admin endpoint/service (with its own guard), AdminJS, or a direct data-access call. Trying to express "writable for role A, not role B" through one shared CRUD DTO isn't possible; model it as a separate endpoint instead.
97
97
 
98
- > **Migrating off `restrictedFields`.** Earlier versions had a `restrictedFields: ['x']` option on a permission action that stripped keys from the body per role. It has been **removed**. Replace it: for a field no client should write, use `/// @NoWrite` (or `/// @Role(none)`); for one endpoint, use `OmitType`; for a value the server owns (like an owner id), use `setUserIdField`; for a genuinely per-role write difference, use a dedicated endpoint as above.
98
+ > **Migrating off `restrictedFields`.** Earlier versions had a `restrictedFields: ['x']` option on a permission action that stripped keys from the body per role. It has been **removed**. Replace it: for a field no client should write, use `/// @NoWrite` (or `/// @Role(none)`); for one endpoint, use `OmitType`; for a value the server owns (like an owner id), use `set` on the permission rule; for a genuinely per-role write difference, use a dedicated endpoint as above.
99
99
 
100
100
  ---
101
101
 
@@ -159,7 +159,7 @@ Consequence for CRUD requests:
159
159
  Note the two layers behave differently on purpose:
160
160
 
161
161
  - **The proxy enforcement is fully recursive** — it authorizes nested `create`/`connect` at *any* depth (each level checked against that model's `create` / `connect` rule). So deep nesting is safe wherever it can reach the proxy.
162
- - **The generated DTOs cap the HTTP surface at one level.** Deep nesting simply isn't expressible through generic CRUD.
162
+ - **The generated DTOs cap the HTTP surface at one level.** Deep nesting isn't expressible through generic CRUD.
163
163
 
164
164
  **To do multi-level nested writes, use an explicit controller/service method** that calls `prismaService.model.<x>.create(...)` with the deep payload — the proxy still enforces the `create`/`connect` allowlist recursively there. Do not try to widen the generated DTOs to accept deeper nesting.
165
165
 
package/docs/generate.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Code generation — `generate` and `generate models`
2
2
 
3
- AppX Core generates code from your Prisma schema through **two commands with different responsibilities**. The split exists so that keeping generated types in sync (safe to automate) is separate from scaffolding CRUD modules (which writes code you own and edits `app.module.ts`).
3
+ AppX Core generates code from your Prisma schema through **two commands with different responsibilities**. The split exists so that keeping generated types in sync (automatable) is separate from scaffolding CRUD modules (which writes code you own and edits `app.module.ts`).
4
4
 
5
5
  | Command | What it does | Writes | Safe in CI? |
6
6
  |---|---|---|---|
@@ -13,7 +13,7 @@ AppX Core generates code from your Prisma schema through **two commands with dif
13
13
  appx-core generate
14
14
  ```
15
15
 
16
- Runs `prisma generate` (which emits both the Prisma client and the prisma-nestjs-graphql artifacts) and regenerates the DTO base classes under `src/generated/dto/**`. It **never** writes `src/modules/**` and **never** edits `src/app.module.ts`, so it is safe to run in CI, a `postinstall`, or a predeploy step, and safe to re-run any number of times.
16
+ Runs `prisma generate` (which emits both the Prisma client and the prisma-nestjs-graphql artifacts) and regenerates the DTO base classes under `src/generated/dto/**`. It **never** writes `src/modules/**` and **never** edits `src/app.module.ts`, so it can run in CI, a `postinstall`, or a predeploy step, and is idempotent.
17
17
 
18
18
  Run it **every time you change the Prisma schema** — it refreshes the client, GraphQL types, and DTO bases in one step (no separate `prisma generate` needed). Because everything it writes lives under the gitignored `src/generated/`, it never produces a diff in your committed code.
19
19
 
package/docs/graphql.md CHANGED
@@ -224,10 +224,9 @@ Read from `@Parent()`, not the raw Prisma client — re-querying bypasses ABAC.
224
224
 
225
225
  ---
226
226
 
227
- ## Not available yet
227
+ ## Aggregations
228
228
 
229
- - **Mutations** (create/update/delete) — use REST.
230
- - **Full `aggregate`** (sum/avg/min/max/groupBy) — only `count` is exposed today.
229
+ Full `aggregate` (sum/avg/min/max/groupBy) is not available yet — `count` and nested `_count` are exposed today.
231
230
 
232
231
  ---
233
232
 
@@ -13,12 +13,11 @@ Read this before using any method or surface not explicitly covered in [permissi
13
13
  - Need a count? Use `count(...)` — it **is** supported and falls back to your `findMany` rule.
14
14
  - Need a genuine aggregate behind trusted access? Compute it inside a `withExposedModels(...)` block (or a bypass) with your own authorization, and never expose it to untrusted roles until aggregate ABAC lands.
15
15
 
16
- ### GraphQL — writes and `aggregate`
16
+ ### GraphQL — `aggregate`
17
17
 
18
- GraphQL currently supports **read queries only** (`findAll<Model>s` / `findFirst<Model>` / `findOne<Model>`), with ABAC applied. See **[graphql.md](./graphql.md)** for how to use it, including nested queries.
18
+ GraphQL is a read-only API — namespaced `find` / `get` / `count` per model, with ABAC applied. See **[graphql.md](./graphql.md)** for how to use it, including nested queries.
19
19
 
20
- - **Mutations (create / update / delete) are not available yet** — use the REST CRUD endpoints for writes.
21
- - **`aggregate` is not ready** — same caveat as REST above.
20
+ - **`aggregate` is not ready** — same caveat as REST above; `count` and nested `_count` are available.
22
21
 
23
22
  Let the generated resolvers serve the data (they go through `PrismaService`); a custom resolver that reaches the raw Prisma client would not get ABAC.
24
23
 
@@ -32,11 +31,11 @@ Let the generated resolvers serve the data (they go through `PrismaService`); a
32
31
 
33
32
  ### Including an unreadable to-one relation drops the parent row
34
33
 
35
- `include`/`select` of a `belongsTo` relation whose target the caller can't read removes the **whole parent row** (returns `null` / omits it), rather than nulling just the relation. To-many relations filter in place and keep the parent. Fails closed; intentional. See [data-access.md](./data-access.md#relations-in-conditions-and-includes).
34
+ `include`/`select` of a `belongsTo` relation whose target the caller can't read removes the **whole parent row** (returns `null` / omits it), rather than nulling just the relation — at any depth, so an unreadable `project.owner` drops the root row. Ordering by a related to-one field has the same effect. To-many relations filter in place and keep the parent. Fails closed; intentional. See [data-access.md](./data-access.md#relations-in-conditions-and-includes).
36
35
 
37
- ### `_count` pseudo-field filtering is not implemented
36
+ ### Ordering by a to-many count needs an unconditional read rule
38
37
 
39
- You can `select: { _count: ... }`, but ABAC conditions are **not** applied inside a `_count` sub-selection. Don't rely on `_count` to reflect only rows the caller may see.
38
+ `orderBy: { tasks: { _count: 'desc' } }` is allowed only when the role reads the related model with `'ALL'` (or it is exposed); with a conditional rule it throws `403`, since the count can't be limited to readable rows. Select `_count` (which is scoped) and sort in code, or expose the model for that request.
40
39
 
41
40
  ### Nested writes over HTTP CRUD are one level deep
42
41
 
@@ -47,10 +46,10 @@ Generated CRUD accepts nested `create`/`connect` **one level deep only** — a `
47
46
  ## Sharp edges to know about
48
47
 
49
48
  - **No per-role field strip.** A field is either writable through the generated DTO or not (`/// @NoWrite` / `OmitType`). There is no way to accept a field for one role and reject it for another on the same CRUD endpoint — model a privileged column as `/// @NoWrite` + a dedicated endpoint. (The old `restrictedFields` option was removed — see [dtos.md](./dtos.md#per-role-differences).)
50
- - **Per-call bypass options are only on three accessors.** `{ BYPASS_FILTERING, BYPASS_OMISSION }` are available on `prismaService.user` / `.session` / `.userRefreshToken`. For an unfiltered read of another model, use `withExposedModels([...])` or `@ExposeModels(...)`, not a per-call option on `prisma.model.<x>`.
49
+ - **Per-call bypass options are untyped.** `{ BYPASS_FILTERING, BYPASS_OMISSION }` work as the second argument of every proxied model method, but the method types don't declare it. For an unfiltered read of a model, prefer `withExposedModels([...])` or `@ExposeModels(...)`.
51
50
  - **Role strings are not type-checked.** `PermissionsConfig` keys are plain strings; a typo (`ADIMN`) silently yields "no permissions" → default-deny for that role. Double-check role spelling against your `Role` enum.
52
51
  - **No audit logging of denials.** A refused request returns `403` but emits no structured audit event; per-request diagnostics are opt-in via `prismaService.debugQueries(true)`.
53
- - **Exposed models are a full bypass.** `@ExposeModels` / `@Permission(a, [models])` / `withExposedModels` disable row *and* field filtering for the listed models within that scope. Keep the scope as narrow as possible.
52
+ - **Exposed models are a full bypass.** `@ExposeModels` / `@Permission(a, [models])` / `withExposedModels` disable row filtering for the listed models within that scope (field omission still applies — add `BYPASS_OMISSION` to read `@Role`-hidden columns). Keep the scope as narrow as possible.
54
53
  - **`connect` defaults to one rule per target model; override per relation when needed.** By default every foreign key that points at model `T` is authorized by the single `T.connect` rule for the role. When a specific relation needs a different (stricter) rule — "a class's teacher must be a teacher" — declare a **relation-scoped** `connect` on the source model (`Source[role].relations[field].connect`); it is ANDed with `T.connect`, or stands alone if `T` has none. See [permissions.md](./permissions.md#two-places-to-declare-a-connect-rule). A relation rule can only strengthen, never loosen, the target rule.
55
54
  - **A string `User.id` needs its two foreign keys changed with it.** `User.id` may be `String` (uuid/cuid) instead of the scaffold's `Int` — the framework is id-type-agnostic and coerces accordingly (see [authentication.md](./authentication.md#using-a-string-uuid--cuid-userid)). But `Session.userId` and `UserRefreshToken.userId` must be switched to `String` in the same edit; leaving one as `Int` is a schema/relation mismatch Prisma will reject at migrate time. Change all three together.
56
55
  - **The config is validated at boot; a bad config stops the app.** A create condition that references a relation, or a required foreign key whose target lacks a `connect` rule for a role that can create, is a startup error (not a runtime one). This is deliberate — the alternative is a silent runtime `403`. The error message names exactly what to fix.
@@ -42,7 +42,7 @@ Shape: `PermissionsConfig[Model][Role][action]`, where each `action` value is ei
42
42
 
43
43
  - **`'ALL'`** — unrestricted for that action, or
44
44
  - **`{ conditions: <where> }`** — an ABAC rule. `conditions` is Prisma `where`-syntax (`AND`/`OR`/`NOT`, relation filters `some`/`every`/`none`, operators `in`/`not`/`lt`/…). `$USER_ID` is substituted with the caller's id at query time.
45
- - Optionally with **`restrictedFields`** and/or **`setUserIdField`** (see below).
45
+ - Optionally with **`set`** on a `create` / `update` rule — columns the server writes (see below).
46
46
 
47
47
  **Model and role keys are matched case-insensitively** against the model name and the caller's role string.
48
48
 
@@ -122,6 +122,8 @@ To keep configs compact, a missing action falls back:
122
122
  - **`findFirst` / `findUnique`-shape reads → `findMany`**.
123
123
  - **`createMany` → `create`**, **`updateMany` → `update`**, **`deleteMany` → `delete`** (a batch action inherits the singular rule).
124
124
 
125
+ Both chains apply in the `RbacGuard` and in the proxy. A custom action (`@Permission('getTeam')`, `@Permission('findAllInvoices')`) never inherits a read rule — it needs its own entry.
126
+
125
127
  The `*Many` → singular fallback is **one-directional**: the singular is canonical. Declaring only `updateMany` does *not* enable `update`; declare `update` (or `create`/`delete`) and the `*Many` form is covered automatically. This holds in **both** the `RbacGuard` and the proxy.
126
128
 
127
129
  So a minimal USER config often needs just `findMany`, `create`, `update`, `delete`.
@@ -130,13 +132,19 @@ So a minimal USER config often needs just `findMany`, `create`, `update`, `delet
130
132
 
131
133
  - **`/// @Role(ADMIN)` on a schema column** — only listed roles can *read* that field. For everyone else the proxy omits it from results (it comes back `undefined`). `/// @Role(none)` hides it from all roles. Applies at every depth, including nested relation selections.
132
134
  - **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles). Use it for privileged columns (e.g. `role`) that should be set out-of-band, not through generic CRUD.
133
- - **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership). **It does not exempt the field from the `connect` requirement**: `ownerId` is still a foreign key, and every relationship a write establishes is authorized. Declare a **relation-scoped** self-connect rather than a blanket rule on the target:
135
+ - **`set: { ownerId: '$USER_ID' }`** on a `create` / `update` rule — values the server writes on every write that rule allows, overriding what the client sent (so a client can't forge ownership). Use it for the owner, audit columns (`updatedById`), or a value a role is forced to (`status: 'draft'`). It applies on every write path — generated CRUD, your services, nested creates. Pair the column with `/// @NoWrite` so the DTO doesn't accept it either.
136
+
137
+ Set values are authorized like any other: they must satisfy the rule's `conditions`, and **a set foreign key needs a `connect` rule** — the app refuses to boot without one. Declare a **relation-scoped** self-connect rather than a blanket rule on the target:
134
138
 
135
139
  ```ts
136
140
  Album: {
137
141
  USER: {
138
- create: {setUserIdField: 'ownerId'},
139
- relations: {owner: {connect: {conditions: {id: '$USER_ID'}}}},
142
+ create: {set: {ownerId: '$USER_ID'}},
143
+ updateMany: {conditions: {ownerId: '$USER_ID'}, set: {updatedById: '$USER_ID'}},
144
+ relations: {
145
+ owner: {connect: {conditions: {id: '$USER_ID'}}},
146
+ updatedBy: {connect: {conditions: {id: '$USER_ID'}}},
147
+ },
140
148
  },
141
149
  },
142
150
  ```
@@ -174,7 +182,8 @@ isEmailAvailable(@Query('email') email: string) { /* reads User unfiltered */ }
174
182
  ```
175
183
 
176
184
  - Sets **no action**, so `RbacGuard` demands no permission (a public / `GUEST` route works with no `GUEST` rule for the model).
177
- - The listed models **skip row and field filtering for that request only** (scoped via async context — it does not make the model globally readable).
185
+ - On writes, an exposed model skips its own `create` rule, but a foreign key it sets is still authorized by the **target's** `connect` rule unless the target is exposed too.
186
+ - The listed models **skip row filtering for that request only** — `@Role` field omission still applies (scoped via async context — it does not make the model globally readable).
178
187
  - Case-insensitive: `@ExposeModels('User')` and `@ExposeModels('user')` are equivalent.
179
188
  - Combine with `@Permission(...)` if you also want an action check — exposed models from both decorators are merged.
180
189
  - This is a deliberate **bypass**. Use it narrowly (e.g. registration pre-checks); prefer real ABAC rules where feasible.
@@ -0,0 +1,89 @@
1
+ # File Uploads & Object Storage
2
+
3
+ Accept file uploads over HTTP and store them in Amazon S3, Google Cloud Storage, or the local filesystem. One setup command wires the module; each upload endpoint declares its own size limit, MIME allowlist, and role access.
4
+
5
+ ---
6
+
7
+ ## Setup
8
+
9
+ Run inside your project:
10
+
11
+ ```bash
12
+ appx-core setup:fileupload
13
+ ```
14
+
15
+ The wizard asks for the storage provider (`aws`, `gcp`, or `local`) and the first endpoint (path, max size in MB, file category and MIME types, single or multiple files, allowed roles). It then:
16
+
17
+ - appends the provider's credentials to `.env`,
18
+ - writes `src/config/file-upload.config.ts`,
19
+ - registers the config in `app.module.ts` — `AppxCoreModule.forRoot(PermissionsConfig, fileUploadConfig)`.
20
+
21
+ ### Provider environment variables
22
+
23
+ | Provider | Variables |
24
+ |---|---|
25
+ | `aws` | `AWS_BUCKET_NAME`, `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` — the key pair is optional; without it the AWS SDK's default credential chain applies |
26
+ | `gcp` | `GCP_BUCKET_NAME`, `GCP_PROJECT_ID`, `GCP_KEY_FILE_PATH` (service-account key file) |
27
+ | `local` | none — files are written to `./uploads/` in the project |
28
+
29
+ ---
30
+
31
+ ## Endpoints
32
+
33
+ Each entry in `fileUploadConfig.endpoints` is an upload route. `POST` a `multipart/form-data` request with the file(s) in the `file` field:
34
+
35
+ ```bash
36
+ curl -X POST https://api.example.com/upload/avatar -F "file=@avatar.png"
37
+ ```
38
+
39
+ ```ts
40
+ import {FileUploadModuleOptions} from '@appxdigital/appx-core';
41
+
42
+ export const fileUploadConfig: FileUploadModuleOptions = {
43
+ cloudProvider: 'aws',
44
+ endpoints: [
45
+ {
46
+ endpoint: '/upload/avatar',
47
+ aliases: [],
48
+ maxSize: 5 * 1024 * 1024, // bytes
49
+ allowedTypes: ['image/jpeg', 'image/png'],
50
+ multiple: false,
51
+ roles: ['USER', 'ADMIN'], // or ['ALL'] for unrestricted
52
+ },
53
+ ],
54
+ };
55
+ ```
56
+
57
+ Per request, the module enforces:
58
+
59
+ - **Role** — the caller's role must be in `roles` (`403` otherwise, including unauthenticated callers). `['ALL']` skips the check.
60
+ - **Size** — files above `maxSize` (bytes) are rejected.
61
+ - **Type** — a MIME type outside `allowedTypes` returns `400`. `image/jpg` is treated as `image/jpeg`.
62
+ - **Count** — `multiple: false` accepts one file; `true` accepts several under the same `file` field.
63
+
64
+ To add another endpoint, add an entry to the `endpoints` array.
65
+
66
+ ### Response
67
+
68
+ `{ "message": "File uploaded successfully", "data": … }`, where `data` holds the storage result per file:
69
+
70
+ - `aws` — `{ key, location, … }`. The object key is the original filename, so uploading the same name overwrites the object; `location` is the bucket URL.
71
+ - `gcp` — `{ fileUrl }`. Stored under the original filename (same overwrite behaviour).
72
+ - `local` — `{ filePath, originalName, size, mimeType }`. Names are timestamp-prefixed, so repeated uploads never overwrite.
73
+
74
+ ---
75
+
76
+ ## Uploading from your own code
77
+
78
+ Inject `FileUploadService` to store a file from a custom endpoint or business logic — it delegates to the configured provider:
79
+
80
+ ```ts
81
+ import {FileUploadService} from '@appxdigital/appx-core';
82
+
83
+ constructor(private fileUpload: FileUploadService) {}
84
+
85
+ async importReport(file: Express.Multer.File) {
86
+ const stored = await this.fileUpload.uploadFile(file);
87
+ // stored: the provider result (see Response above)
88
+ }
89
+ ```
@@ -0,0 +1,74 @@
1
+ # Testing
2
+
3
+ Every project created with `appx-core create` ships a ready-to-run test suite: `npm test` provisions a throwaway database container, boots the real `AppModule` with the production middleware stack, and drives it over HTTP. The database in `.env` is never touched — the only requirement is a running Docker.
4
+
5
+ ```bash
6
+ npm test # one-shot run
7
+ npm run test:watch # watch mode
8
+ ```
9
+
10
+ What happens on each run: vitest's global setup starts a disposable MySQL or PostgreSQL container (matching your `DB_PROVIDER`), pushes the Prisma schema into it, and hands its URL to the specs. Every spec file boots the app against that database; the container is destroyed when the run ends.
11
+
12
+ ---
13
+
14
+ ## What ships with a new project
15
+
16
+ | File | Covers |
17
+ |---|---|
18
+ | `test/app.spec.ts` | the app boots and answers on `/` |
19
+ | `test/auth.spec.ts` | register (incl. unknown-field rejection), session login → `/auth/me`, JWT login → `/auth/refresh` — asserted at the database level |
20
+ | `test/isolation.spec.ts` | **route sweep**: every route the app registers is probed as a guest; anything not in `PUBLIC_ROUTES` must deny. A new endpoint with no access rule turns this red the day it lands. |
21
+ | `test/helpers/harness.ts` | the toolkit the specs are built on (below) |
22
+ | `test/setup.ts`, `test/global-setup.ts`, `vitest.config.ts` | container lifecycle + runner config |
23
+
24
+ The harness exports:
25
+
26
+ - `startApp()` / `stopApp()` — boots `AppModule` once per spec file with the same session/passport/`setupCoreSecurity` stack as `src/main.ts`.
27
+ - `api(actor?)` — a supertest agent; pass an actor to send its JWT bearer token, none for a guest.
28
+ - `sessionAgent()` — a cookie-keeping agent for session flows (`POST /auth/login` → `/auth/me`).
29
+ - `registerActor(email?, password?)` — mints a user over real HTTP (register + JWT login) and returns `{ id, email, password, token }`.
30
+ - `prisma` — a **raw** Prisma client (no ABAC): create fixture rows and assert results with it.
31
+ - `truncateAll()` — deletes every row, children before parents; extend it as your schema grows.
32
+
33
+ ---
34
+
35
+ ## Writing a spec
36
+
37
+ The pattern, shown for a generated module (after `appx-core generate models`):
38
+
39
+ ```ts
40
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
41
+ import { api, prisma, registerActor, startApp, stopApp, truncateAll } from './helpers/harness';
42
+
43
+ describe('projects', () => {
44
+ beforeAll(async () => {
45
+ await startApp();
46
+ });
47
+ afterAll(stopApp);
48
+ beforeEach(truncateAll);
49
+
50
+ it("a user cannot read another user's project", async () => {
51
+ const alice = await registerActor();
52
+ const bob = await registerActor();
53
+ const row = await prisma.project.create({ data: { title: 'p', ownerId: bob.id } });
54
+
55
+ const res = await api(alice).get(`/projects/${row.id}`);
56
+ expect(res.status).toBeGreaterThanOrEqual(400);
57
+ // The database is the authority: the row is untouched.
58
+ expect(await prisma.project.count()).toBe(1);
59
+ });
60
+ });
61
+ ```
62
+
63
+ Conventions the shipped specs follow — keep them:
64
+
65
+ - **Actors go through HTTP** (`registerActor`), **fixture rows go through raw `prisma`** — a broken permission rule can't quietly produce an empty fixture and fake a pass.
66
+ - **Assert on the database**, not only the response body. ABAC-denied `updateMany`/`deleteMany` match zero rows and still return `200` — the row count is the authority on whether isolation held.
67
+ - **Extend `truncateAll`** when you add models (children before parents).
68
+ - **A new public route is a deliberate act**: add it to `PUBLIC_ROUTES` in `test/isolation.spec.ts`, one line, reviewed.
69
+
70
+ ---
71
+
72
+ ## Existing projects
73
+
74
+ Projects created before the test scaffold existed can adopt it by copying the files from a fresh scaffold (or from this package's `cli/scaffold/` directory): `vitest.config.ts`, `test/`, the `test`/`test:watch` scripts, and the devDependencies listed in the [CHANGELOG](../CHANGELOG.md) migration note.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.128",
3
+ "version": "0.1.130",
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",
@@ -30,6 +30,7 @@
30
30
  "url": "https://github.com/appxdigital/appx-core/issues"
31
31
  },
32
32
  "dependencies": {
33
+ "@adminjs/nestjs": "~7.0.0",
33
34
  "express": "^5.1.0"
34
35
  },
35
36
  "devDependencies": {
@@ -56,7 +57,6 @@
56
57
  "peerDependencies": {
57
58
  "@adminjs/express": "~6.1.1",
58
59
  "@adminjs/import-export": "~3.0.0",
59
- "@adminjs/nestjs": "~7.0.0",
60
60
  "@adminjs/passwords": "~4.0.0",
61
61
  "@adminjs/prisma": "~5.0.4",
62
62
  "@apollo/server": "^4.11.3",