@appxdigital/appx-core 0.1.128-beta.5 → 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 +25 -0
- package/README.md +21 -18
- package/dist/backoffice/appx-core-admin.module.d.ts.map +1 -1
- package/dist/backoffice/appx-core-admin.module.js +2 -3
- package/dist/modules/auth/refresh-token.strategy.d.ts +1 -3
- package/dist/modules/auth/refresh-token.strategy.d.ts.map +1 -1
- package/dist/modules/auth/refresh-token.strategy.js +9 -6
- package/docs/README.md +3 -1
- package/docs/data-access.md +2 -2
- package/docs/dtos.md +1 -1
- package/docs/generate.md +2 -2
- package/docs/graphql.md +2 -3
- package/docs/limitations.md +3 -4
- package/docs/permissions.md +1 -1
- package/docs/storage.md +89 -0
- package/docs/testing.md +74 -0
- package/package.json +1 -1
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
|
-
|
|
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 (
|
|
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
|
-
###
|
|
608
|
+
### Single-record methods are aliases
|
|
607
609
|
|
|
608
|
-
|
|
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`
|
|
611
|
-
- `
|
|
612
|
-
- `
|
|
612
|
+
- `findUnique()` / `findUniqueOrThrow()` run `findFirst()` / `findFirstOrThrow()`
|
|
613
|
+
- `update()` runs `updateMany()` — returns `{ count }`, not the record
|
|
614
|
+
- `delete()` runs `deleteMany()` — returns `{ count }`
|
|
613
615
|
|
|
614
|
-
|
|
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
|
-
|
|
617
|
-
- When you intend a single-record operation, use a restrictive `where` clause.
|
|
618
|
+
### GraphQL
|
|
618
619
|
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
- **[
|
|
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
|
|
package/docs/data-access.md
CHANGED
|
@@ -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 } })`
|
|
50
|
-
- The TypeScript types reflect this: calling `update()` with a lone unique `where` and expecting a record back is a **compile error
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
##
|
|
227
|
+
## Aggregations
|
|
228
228
|
|
|
229
|
-
|
|
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
|
|
package/docs/limitations.md
CHANGED
|
@@ -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 —
|
|
16
|
+
### GraphQL — `aggregate`
|
|
17
17
|
|
|
18
|
-
GraphQL
|
|
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
|
-
-
|
|
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
|
|
package/docs/permissions.md
CHANGED
|
@@ -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 **`
|
|
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
|
|
package/docs/storage.md
ADDED
|
@@ -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
|
+
```
|
package/docs/testing.md
ADDED
|
@@ -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