@appxdigital/appx-core 0.1.128 → 0.1.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,31 @@ This project follows the `MAJOR.MINOR.PATCH` scheme in `package.json`.
14
14
 
15
15
  ---
16
16
 
17
+ ## [0.1.129] — 2026-08-17
18
+
19
+ ### Added
20
+
21
+ - **Every new project ships a ready-to-run test suite.** `appx-core create` now scaffolds `vitest.config.ts` and `test/` (Vitest + SWC), with `npm test` / `npm run test:watch` scripts and the needed devDependencies. A run provisions a **throwaway database container** via Testcontainers (matching `DB_PROVIDER` — the database in `.env` is never touched; Docker is the only requirement), pushes the Prisma schema, and boots the real `AppModule` with the production middleware stack (session store, passport, `setupCoreSecurity`). Shipped specs: app boot, register (unknown-field rejection included), session login → `/auth/me`, JWT login → `/auth/refresh` — all asserted at the database level — plus a **route sweep** that probes every registered route as a guest and fails on anything non-public that answers `2xx`. See [docs/testing.md](./docs/testing.md).
22
+
23
+ - **Every new project ships an agent guide.** `appx-core create` drops an `AGENTS.md` at the project root (plus a `CLAUDE.md` pointer) — the rules of the road for coding agents working in the project: proxy-only data access, default-deny permissions, generated-code ownership, read/write surfaces, test conventions, and the pre-production checklist (including removing the Hello-World placeholder). It points at `node_modules/@appxdigital/appx-core/docs/` as the source of truth instead of duplicating it.
24
+
25
+ ### Fixed
26
+
27
+ - **`POST /auth/refresh` now works under a default-deny permissions config.** The refresh-token user lookup runs as a framework auth flow (like login and JWT population). Previously, a permissions config with no `GUEST` read rule on `User` — including the scaffold default — returned `403` for every refresh.
28
+ - **AdminJS is loaded lazily.** The backoffice no longer imports `adminjs` at module load time; it resolves through the same dynamic-import path as the other AdminJS packages. Drop-in — enables importing the package under CommonJS test runners and on Node < 20.19.
29
+
30
+ ### Migration
31
+
32
+ > Drop-in for existing projects. To adopt the test suite in a project created before this version:
33
+
34
+ 1. Copy `vitest.config.ts` and the `test/` directory from a freshly created project (or from this package's `cli/scaffold/`).
35
+ 2. Add the scripts: `"test": "vitest run"`, `"test:watch": "vitest"`.
36
+ 3. `npm install -D vitest unplugin-swc @swc/core supertest @types/supertest @nestjs/testing @testcontainers/mysql @testcontainers/postgresql dotenv`.
37
+ 4. Run `npm test` (requires Docker). Extend `test/helpers/harness.ts`'s `truncateAll` and `test/isolation.spec.ts`'s `PUBLIC_ROUTES` as your schema and routes grow.
38
+ 5. Optionally copy `AGENTS.md` (and the `CLAUDE.md` pointer) from `cli/scaffold/`, replacing `{{PROJECT_NAME}}` with your project's name.
39
+
40
+ ---
41
+
17
42
  ## [0.1.128] — 2026-08-04
18
43
 
19
44
  Read-only GraphQL, reworked into a **namespaced, opt-in-per-module** API.
package/README.md CHANGED
@@ -86,6 +86,8 @@ AppX Core puts **security and data-access control first**, while keeping develop
86
86
  - Namespaced, **opt-in per model**: `model { find get count }`
87
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
88
  - Same ABAC as REST (row / field / nested-record filtering; filter & sort limited to readable fields)
89
+ - **Tests out of the box**
90
+ - `npm test` provisions a throwaway database container (Docker) and drives the real app over HTTP — register/login/refresh flows plus a route-sweep that denies guests everywhere non-public
89
91
  - **Developer-friendly extension points**
90
92
  - Add custom endpoints with `@Permission(...)`
91
93
  - Use services for business logic, controllers as transport layer
@@ -160,7 +162,7 @@ appx-core create --yes \
160
162
  --db-user root --db-password secret --db-name my_app_db
161
163
  ```
162
164
 
163
- Flags: `--name`, `--db-provider mysql|postgresql`, `--db-host`, `--db-port`, `--db-user`, `--db-password`, `--db-name`, `--show-output`. File upload isn't configured on this path — run `appx-core setup:fileupload` inside the project afterwards.
165
+ Flags: `--name`, `--db-provider mysql|postgresql`, `--db-host`, `--db-port`, `--db-user`, `--db-password`, `--db-name`, `--show-output`. File upload isn't configured on this path — run `appx-core setup:fileupload` inside the project afterwards (see [docs/storage.md](./docs/storage.md)).
164
166
 
165
167
  ### 3) Configure Environment
166
168
 
@@ -487,7 +489,7 @@ model User {
487
489
  }
488
490
  ```
489
491
 
490
- Use `/// @NoWrite` to exclude a field for **all** roles. For **per-role** control (e.g. only ADMIN may set `role`), use `restrictedFields` on the permission instead of `@NoWrite`.
492
+ A field is either CRUD-writable or it isn't — there is no per-role write control on a generated endpoint. For a privileged field (e.g. `role`), annotate it `/// @NoWrite` and set it through a dedicated endpoint. See [docs/dtos.md](./docs/dtos.md).
491
493
 
492
494
  ---
493
495
 
@@ -558,7 +560,7 @@ export const PermissionsConfig = {
558
560
  };
559
561
  ```
560
562
 
561
- Service (Prisma query remains straightforward; ABAC filtering is applied by the core):
563
+ Service (ABAC filtering is applied by the core):
562
564
 
563
565
  ```ts
564
566
  async getMyProfile() {
@@ -603,22 +605,27 @@ This pattern should be used sparingly and intentionally.
603
605
 
604
606
  ## Limitations
605
607
 
606
- ### Prisma method restrictions (permission safety)
608
+ ### Single-record methods are aliases
607
609
 
608
- Avoid these Prisma methods because they can bypass or complicate granular permission filtering:
610
+ The single-record Prisma methods are aliases to their filter-compatible equivalents — in the TypeScript types and at runtime — and take that method's arguments and return its result. ABAC applies to every call.
609
611
 
610
- - `findUnique` → use `findFirst`
611
- - `delete` → use `deleteMany` with a single-item filter
612
- - `update` → use `updateMany` with a single-item filter
612
+ - `findUnique()` / `findUniqueOrThrow()` run `findFirst()` / `findFirstOrThrow()`
613
+ - `update()` runs `updateMany()` — returns `{ count }`, not the record
614
+ - `delete()` runs `deleteMany()` — returns `{ count }`
613
615
 
614
- Operational guidance:
616
+ They act on **all** matching rows, so pass a restrictive `where` when you intend a single record; a call reaching outside your permissions affects `{ count: 0 }`. Full contract in [docs/data-access.md](./docs/data-access.md#method-contract-important).
615
617
 
616
- - Prefer `findMany`, `findFirst`, `updateMany`, and `deleteMany`.
617
- - When you intend a single-record operation, use a restrictive `where` clause.
618
+ ### GraphQL
618
619
 
619
- ### GraphQL status
620
+ 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)**.
620
621
 
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.
622
+ Aggregations are not yet available (on the roadmap); `count` and nested `_count` cover the common cases today.
623
+
624
+ ---
625
+
626
+ ## Testing
627
+
628
+ Every new project ships a ready-to-run suite: `npm test` starts a disposable database container matching your `DB_PROVIDER`, pushes the schema, boots the real `AppModule` with the production middleware stack, and runs HTTP-level specs — auth flows asserted at the database level, and a route sweep that probes every registered route as a guest. Docker is the only requirement; the database in `.env` is never touched. See **[docs/testing.md](./docs/testing.md)** for the harness API and how to write specs for your modules.
622
629
 
623
630
  ---
624
631
 
@@ -636,15 +643,11 @@ No. Generated NestJS modules are **editable** and intended to be extended.
636
643
 
637
644
  Planned and under consideration:
638
645
 
639
- - Production-ready GraphQL
640
- - Object storage patterns (S3-friendly out-of-the-box support)
646
+ - GraphQL aggregations
641
647
  - Forgot-password flow
642
648
  - Email templates + sending primitives
643
649
  - Push notifications primitives
644
650
  - WebSockets support
645
- - Stronger testing baseline (unit tests scaffolding and patterns)
646
- - “Agent-ready” development guidance (`agents.txt`, conventions for coding agents)
647
- - Optional AI-assisted project scaffolding
648
651
 
649
652
  ---
650
653
 
@@ -1 +1 @@
1
- {"version":3,"file":"appx-core-admin.module.d.ts","sourceRoot":"","sources":["../../src/backoffice/appx-core-admin.module.ts"],"names":[],"mappings":"AAGA,OAAO,EAAC,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AAO7D,OAAO,EAAC,eAAe,EAAC,MAAM,kCAAkC,CAAC;AAIjE,OAAO,EAAC,qBAAqB,EAAC,MAAM,yCAAyC,CAAC;AAE9E,qBAEa,mBAAmB;IAC5B,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,gBAAgB,EAAE,qBAAqB,GAAG,OAAO,CAAC,aAAa,CAAC;CAG3G"}
1
+ {"version":3,"file":"appx-core-admin.module.d.ts","sourceRoot":"","sources":["../../src/backoffice/appx-core-admin.module.ts"],"names":[],"mappings":"AAGA,OAAO,EAAC,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AAM7D,OAAO,EAAC,eAAe,EAAC,MAAM,kCAAkC,CAAC;AAIjE,OAAO,EAAC,qBAAqB,EAAC,MAAM,yCAAyC,CAAC;AAE9E,qBAEa,mBAAmB;IAC5B,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,eAAe,EAAE,gBAAgB,EAAE,qBAAqB,GAAG,OAAO,CAAC,aAAa,CAAC;CAG3G"}
@@ -37,7 +37,6 @@ const common_1 = require("@nestjs/common");
37
37
  const utils_1 = require("./utils");
38
38
  const utils_2 = require("../config/utils");
39
39
  const nestjs_request_context_1 = require("nestjs-request-context");
40
- const adminjs_1 = require("adminjs");
41
40
  const argon2 = __importStar(require("argon2"));
42
41
  const prisma_service_1 = require("../prisma/prisma.service");
43
42
  let AppxCoreAdminModule = exports.AppxCoreAdminModule = class AppxCoreAdminModule {
@@ -54,7 +53,7 @@ function lowerCase(name) {
54
53
  }
55
54
  async function createAdminJsModule(adminConfig, permissionConfig) {
56
55
  // Due to AdminJS only allowing ESM now, we need to use dynamic imports to load the modules, this function can be found within src/backoffice/utils.ts
57
- const { default: AdminJS } = await (0, utils_1.dynamicImport)('adminjs');
56
+ const { default: AdminJS, BaseRecord } = await (0, utils_1.dynamicImport)('adminjs');
58
57
  const { Database, Resource, convertParam } = await (0, utils_1.dynamicImport)('@adminjs/prisma');
59
58
  const { AdminModule } = await (0, utils_1.dynamicImport)('@adminjs/nestjs');
60
59
  const { default: importExportFeature } = await (0, utils_1.dynamicImport)('@adminjs/import-export');
@@ -106,7 +105,7 @@ async function createAdminJsModule(adminConfig, permissionConfig) {
106
105
  });
107
106
  if (!result)
108
107
  return null;
109
- return new adminjs_1.BaseRecord(this.prepareReturnValues(result), this);
108
+ return new BaseRecord(this.prepareReturnValues(result), this);
110
109
  }
111
110
  async update(pk, params = {}) {
112
111
  const idProperty = this.properties().find((property) => property.isId());
@@ -2,15 +2,13 @@ import { Strategy } from 'passport-jwt';
2
2
  import { ConfigService } from '@nestjs/config';
3
3
  import { Request } from 'express';
4
4
  import { PrismaService } from '../../prisma/prisma.service';
5
- import { UserService } from '../user/user.service';
6
5
  declare const RefreshTokenStrategy_base: new (...args: [opt: import("passport-jwt").StrategyOptionsWithRequest] | [opt: import("passport-jwt").StrategyOptionsWithoutRequest]) => Strategy & {
7
6
  validate(...args: any[]): unknown;
8
7
  };
9
8
  export declare class RefreshTokenStrategy extends RefreshTokenStrategy_base {
10
9
  private readonly configService;
11
10
  private readonly prisma;
12
- private readonly userService;
13
- constructor(configService: ConfigService, prisma: PrismaService, userService: UserService);
11
+ constructor(configService: ConfigService, prisma: PrismaService);
14
12
  validate(req: Request, payload: any): Promise<any>;
15
13
  }
16
14
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"refresh-token.strategy.d.ts","sourceRoot":"","sources":["../../../src/modules/auth/refresh-token.strategy.ts"],"names":[],"mappings":"AAEA,OAAO,EAAa,QAAQ,EAAC,MAAM,cAAc,CAAC;AAClD,OAAO,EAAC,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAC,OAAO,EAAC,MAAM,SAAS,CAAC;AAChC,OAAO,EAAC,aAAa,EAAC,MAAM,6BAA6B,CAAC;AAC1D,OAAO,EAAC,WAAW,EAAC,MAAM,sBAAsB,CAAC;;;;AAEjD,qBACa,oBAAqB,SAAQ,yBAAyC;IAE3E,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,WAAW;gBAFX,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,aAAa,EACrB,WAAW,EAAE,WAAW;IAcvC,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG;CAmC5C"}
1
+ {"version":3,"file":"refresh-token.strategy.d.ts","sourceRoot":"","sources":["../../../src/modules/auth/refresh-token.strategy.ts"],"names":[],"mappings":"AAEA,OAAO,EAAa,QAAQ,EAAC,MAAM,cAAc,CAAC;AAClD,OAAO,EAAC,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAC,OAAO,EAAC,MAAM,SAAS,CAAC;AAChC,OAAO,EAAC,aAAa,EAAC,MAAM,6BAA6B,CAAC;;;;AAE1D,qBACa,oBAAqB,SAAQ,yBAAyC;IAE3E,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,aAAa;IAcpC,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG;CA0C5C"}
@@ -15,9 +15,8 @@ const passport_1 = require("@nestjs/passport");
15
15
  const passport_jwt_1 = require("passport-jwt");
16
16
  const config_1 = require("@nestjs/config");
17
17
  const prisma_service_1 = require("../../prisma/prisma.service");
18
- const user_service_1 = require("../user/user.service");
19
18
  let RefreshTokenStrategy = exports.RefreshTokenStrategy = class RefreshTokenStrategy extends (0, passport_1.PassportStrategy)(passport_jwt_1.Strategy, 'jwt-refresh') {
20
- constructor(configService, prisma, userService) {
19
+ constructor(configService, prisma) {
21
20
  const refreshSecret = configService.get('JWT_REFRESH_SECRET');
22
21
  if (!refreshSecret) {
23
22
  throw new Error('JWT_REFRESH_SECRET is not defined in environment variables!');
@@ -29,13 +28,18 @@ let RefreshTokenStrategy = exports.RefreshTokenStrategy = class RefreshTokenStra
29
28
  });
30
29
  this.configService = configService;
31
30
  this.prisma = prisma;
32
- this.userService = userService;
33
31
  }
34
32
  async validate(req, payload) {
35
33
  const refreshToken = req.body.refreshToken;
36
34
  if (!refreshToken)
37
35
  throw new common_1.UnauthorizedException('Refresh token not found');
38
- const user = await this.userService.findByField("id", payload.sub);
36
+ // Auth-flow lookup: the refresh request is unauthenticated (GUEST), so
37
+ // this must bypass ABAC like every other auth lookup in this module.
38
+ const user = await this.prisma.user.findFirst({
39
+ where: { id: payload.sub },
40
+ }, {
41
+ BYPASS_FILTERING: true,
42
+ });
39
43
  if (!user) {
40
44
  throw new common_1.UnauthorizedException('User not found');
41
45
  }
@@ -65,6 +69,5 @@ let RefreshTokenStrategy = exports.RefreshTokenStrategy = class RefreshTokenStra
65
69
  exports.RefreshTokenStrategy = RefreshTokenStrategy = __decorate([
66
70
  (0, common_1.Injectable)(),
67
71
  __metadata("design:paramtypes", [config_1.ConfigService,
68
- prisma_service_1.PrismaService,
69
- user_service_1.UserService])
72
+ prisma_service_1.PrismaService])
70
73
  ], RefreshTokenStrategy);
package/docs/README.md CHANGED
@@ -10,7 +10,9 @@ Authoritative usage documentation for `@appxdigital/appx-core`. **This folder is
10
10
  - **[data-access.md](./data-access.md)** — how you talk to the database: the Prisma proxy (`prisma.model.*`), how ABAC filtering and field omission are enforced on every call, the redirected single-record methods, relation behaviour, bypass hatches, and transactions.
11
11
  - **[authentication.md](./authentication.md)** — the built-in `/auth` surface, and **how to override framework internals** (extend the exported `AuthController`/`AuthService`/`RegisterDto`/Passport strategies and register your own at the same prefix via `AuthModule.forRoot({ controller: false })`) — e.g. adding `name` to registration.
12
12
  - **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
13
- - **[limitations.md](./limitations.md)** — **known limitations and features that are NOT ready for use** (e.g. `aggregate`, GraphQL writes). Read this before relying on any method not covered in the guides above.
13
+ - **[testing.md](./testing.md)** — the test suite every new project ships with: `npm test` on a throwaway DB container, the HTTP harness (`api(actor)`, `registerActor`, raw `prisma`), and the route-sweep backstop.
14
+ - **[storage.md](./storage.md)** — file uploads & object storage: the `setup:fileupload` wizard, per-endpoint size/MIME/role rules, and the S3 / GCS / local providers.
15
+ - **[limitations.md](./limitations.md)** — **known limitations and features that are NOT ready for use** (e.g. `aggregate`). Read this before relying on any method not covered in the guides above.
14
16
 
15
17
  ## The one-paragraph mental model
16
18
 
@@ -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
 
package/docs/dtos.md CHANGED
@@ -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
 
@@ -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 **`setUserIdField`** (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
 
@@ -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.129",
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",