@appxdigital/appx-core 0.1.125 → 0.1.126

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,27 @@ This project follows the `MAJOR.MINOR.PATCH` scheme in `package.json`.
14
14
 
15
15
  ---
16
16
 
17
+ ## [0.1.126] — 2026-07-31
18
+
19
+ ### Fixed
20
+
21
+ - **A freshly scaffolded project now boots.** `cli/scaffold/src/app.module.ts` never imported the `PrismaModule` it ships, and **no framework module provides `PrismaService`** — so `AuthModule` (and the admin module) had an unresolvable dependency and a brand-new project failed at startup with `UnknownDependenciesException`. It only appeared to work once `generate models` had been run, because generated modules import that `@Global()` module as a side effect. The scaffold now imports it explicitly. **Scaffold-only** — existing projects already register it (directly or via a generated module) and need no change.
22
+
23
+ ### Changed
24
+
25
+ - **Generated controller routes are kebab-case.** `@Controller()` prefixes were `model.toLowerCase() + 's'`, which ran words together and doubled a trailing `s` (`AlbumMember` → `albummembers`, `PublishSettings` → `publishsettingss`). They now match the file names the same command generates: `album-members`, `publish-settings`, `type-samples`. No English pluralization rules are applied (model names are not necessarily English) — the kebab-cased name gets an `s` unless it already ends in one.
26
+
27
+ > **Only affects newly generated controllers.** Generated controllers are hand-owned (written once, never overwritten), so existing routes are untouched and no deployed API changes. A project that generates *new* modules after upgrading will have both styles side by side; rename the older `@Controller()` prefixes by hand if you want consistency.
28
+
29
+ ### Documentation
30
+
31
+ - **`permissions.md`** gains a "migrating from row-level security" callout — transcribing an RLS `INSERT` policy that checks a joined table (`create: {conditions: {album: …}}`) is a boot error here, because create conditions judge the created model's own scalar fields only and everything crossing a relation belongs to `connect`.
32
+ - **`setUserIdField` and `connect` documented together.** A server-set owner FK still needs a `connect` rule; the docs now show the relation-scoped self-connect (`relations.owner.connect: {conditions: {id: '$USER_ID'}}`) and explain why that is preferable to a blanket `connect: 'ALL'` on the target. The boot validator emits this same targeted hint when the offending column is the one named by `setUserIdField`.
33
+
34
+ Drop-in: no changes required in existing projects.
35
+
36
+ ---
37
+
17
38
  ## [0.1.125] — 2026-07-29
18
39
 
19
40
  A string (uuid/cuid) `User.id` is now supported end-to-end, and the CLI's `create` gains a non-interactive mode plus far better failure diagnostics.
@@ -40,7 +40,7 @@ import { CoreController, Permission } from '@appxdigital/appx-core';
40
40
  import { Create${model}Dto } from './dto/create-${folder}.dto';
41
41
  import { Update${model}Dto } from './dto/update-${folder}.dto';
42
42
 
43
- @Controller('${model.toLowerCase()}s')
43
+ @Controller('${(0, utils_1.modelRoutePath)(model)}')
44
44
  export class ${model}Controller extends CoreController<${model}> {
45
45
  constructor(protected readonly service: ${model}Service) {
46
46
  super(service);
@@ -15,6 +15,22 @@
15
15
  export declare const runProjectBin: (bin: string, args: string, cwd?: string) => void;
16
16
  export declare const kebabToPascalCase: (str: string) => string;
17
17
  export declare const pascalToKebabCase: (str: string) => string;
18
+ /**
19
+ * The HTTP route prefix for a model's generated controller: the kebab-case model
20
+ * name plus a plural 's'.
21
+ *
22
+ * AlbumMember → album-members
23
+ * PublishSettings → publish-settings (already ends in 's' — not doubled)
24
+ * Tenant → tenants
25
+ *
26
+ * Kebab-case so the route matches the file names this generator emits
27
+ * (`album-member.controller.ts`) instead of running the words together.
28
+ *
29
+ * Deliberately NOT a pluralization engine — no English -ies/-es rules, since
30
+ * model names here are not necessarily English. The only guard is against
31
+ * emitting a doubled 's'.
32
+ */
33
+ export declare const modelRoutePath: (name: string) => string;
18
34
  export declare const createFileIfNotExists: (filePath: string, content: string) => void;
19
35
  /**
20
36
  * Banner stamped on every fully-generated file. These files live under
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/config/utils.ts"],"names":[],"mappings":"AAuBA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa,QAAS,MAAM,QAAQ,MAAM,QAAO,MAAM,KAAmB,IAatF,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAS,MAAM,WAK5C,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAS,MAAM,WAK5C,CAAC;AAEF,eAAO,MAAM,qBAAqB,aAAc,MAAM,WAAW,MAAM,SAStE,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,QAGoD,CAAC;AAElF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAAc,MAAM,WAAW,MAAM,SAInE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,UAA0C,CAAC;AAExE;;;;;GAKG;AACH,eAAO,MAAM,WAAW,cAAe,MAAM,KAAG,MAAsC,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,QAAO,GAAG,EAInC,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,GAAG,EAAuE,CAAC;AAEzG,iFAAiF;AACjF,eAAO,MAAM,YAAY,cAAe,MAAM,KAAG,OAGhD,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,aAAc,MAAM,EAAE,KAAG,IAAI,MAAM,CAQnE,CAAC;AAEF,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAAO,IAAI,MAAM,CAYxC,CAAC"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/config/utils.ts"],"names":[],"mappings":"AAuBA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa,QAAS,MAAM,QAAQ,MAAM,QAAO,MAAM,KAAmB,IAatF,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAS,MAAM,WAK5C,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAS,MAAM,WAK5C,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,SAAU,MAAM,KAAG,MAG7C,CAAC;AAEF,eAAO,MAAM,qBAAqB,aAAc,MAAM,WAAW,MAAM,SAStE,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,QAGoD,CAAC;AAElF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAAc,MAAM,WAAW,MAAM,SAInE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,UAA0C,CAAC;AAExE;;;;;GAKG;AACH,eAAO,MAAM,WAAW,cAAe,MAAM,KAAG,MAAsC,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,QAAO,GAAG,EAInC,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,GAAG,EAAuE,CAAC;AAEzG,iFAAiF;AACjF,eAAO,MAAM,YAAY,cAAe,MAAM,KAAG,OAGhD,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,aAAc,MAAM,EAAE,KAAG,IAAI,MAAM,CAQnE,CAAC;AAEF,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAAO,IAAI,MAAM,CAYxC,CAAC"}
@@ -23,7 +23,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
23
23
  return result;
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.ownedModels = exports.modelsServedBySource = exports.moduleExists = exports.loadModels = exports.loadAllModels = exports.modelFolder = exports.FRAMEWORK_MODELS = exports.writeGeneratedFile = exports.GENERATED_BANNER = exports.createFileIfNotExists = exports.pascalToKebabCase = exports.kebabToPascalCase = exports.runProjectBin = void 0;
26
+ exports.ownedModels = exports.modelsServedBySource = exports.moduleExists = exports.loadModels = exports.loadAllModels = exports.modelFolder = exports.FRAMEWORK_MODELS = exports.writeGeneratedFile = exports.GENERATED_BANNER = exports.createFileIfNotExists = exports.modelRoutePath = exports.pascalToKebabCase = exports.kebabToPascalCase = exports.runProjectBin = void 0;
27
27
  const fs = __importStar(require("fs"));
28
28
  const path = __importStar(require("path"));
29
29
  const child_process_1 = require("child_process");
@@ -88,6 +88,26 @@ const pascalToKebabCase = (str) => {
88
88
  .toLowerCase();
89
89
  };
90
90
  exports.pascalToKebabCase = pascalToKebabCase;
91
+ /**
92
+ * The HTTP route prefix for a model's generated controller: the kebab-case model
93
+ * name plus a plural 's'.
94
+ *
95
+ * AlbumMember → album-members
96
+ * PublishSettings → publish-settings (already ends in 's' — not doubled)
97
+ * Tenant → tenants
98
+ *
99
+ * Kebab-case so the route matches the file names this generator emits
100
+ * (`album-member.controller.ts`) instead of running the words together.
101
+ *
102
+ * Deliberately NOT a pluralization engine — no English -ies/-es rules, since
103
+ * model names here are not necessarily English. The only guard is against
104
+ * emitting a doubled 's'.
105
+ */
106
+ const modelRoutePath = (name) => {
107
+ const kebab = (0, exports.pascalToKebabCase)(name);
108
+ return kebab.endsWith('s') ? kebab : `${kebab}s`;
109
+ };
110
+ exports.modelRoutePath = modelRoutePath;
91
111
  const createFileIfNotExists = (filePath, content) => {
92
112
  console.log(`Creating ${filePath}...`);
93
113
  if (!fs.existsSync(filePath)) {
@@ -1 +1 @@
1
- {"version":3,"file":"permissions-validator.d.ts","sourceRoot":"","sources":["../../src/prisma/permissions-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AAErF,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,SAAS,CAAC;AAEnD,MAAM,WAAW,YAAY;IACzB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,IAAI,EAAE,YAAY,CAAC;IACnB,wGAAwG;IACxG,UAAU,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;AAE7D,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,CAAC;AAE7C,MAAM,MAAM,SAAS,GACf,8BAA8B,GAC9B,0BAA0B,GAC1B,iBAAiB,GACjB,uBAAuB,GACvB,kBAAkB,CAAC;AAEzB,MAAM,WAAW,eAAe;IAC5B,KAAK,EAAE,UAAU,CAAC;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAmBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CACrC,MAAM,EAAE,qBAAqB,EAC7B,SAAS,EAAE,eAAe,GAC3B,eAAe,EAAE,CAmHnB"}
1
+ {"version":3,"file":"permissions-validator.d.ts","sourceRoot":"","sources":["../../src/prisma/permissions-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AAErF,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,SAAS,CAAC;AAEnD,MAAM,WAAW,YAAY;IACzB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,IAAI,EAAE,YAAY,CAAC;IACnB,wGAAwG;IACxG,UAAU,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;AAE7D,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,CAAC;AAE7C,MAAM,MAAM,SAAS,GACf,8BAA8B,GAC9B,0BAA0B,GAC1B,iBAAiB,GACjB,uBAAuB,GACvB,kBAAkB,CAAC;AAEzB,MAAM,WAAW,eAAe;IAC5B,KAAK,EAAE,UAAU,CAAC;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAmBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CACrC,MAAM,EAAE,qBAAqB,EAC7B,SAAS,EAAE,eAAe,GAC3B,eAAe,EAAE,CA+HnB"}
@@ -99,7 +99,19 @@ function validatePermissionsConfig(config, relations) {
99
99
  }
100
100
  if (relRule || hasConnectRule(r.model, role))
101
101
  continue;
102
- const fix = `Declare '${r.model}.${role}.connect' (or 'ALL'), or scope it to this relation with '${modelKey}.${role}.relations.${r.field}.connect'.`;
102
+ // A server-set owner FK (`setUserIdField`) still needs a connect
103
+ // rule — the value is trusted, but the framework authorizes every
104
+ // relationship a write establishes, uniformly. Point at the
105
+ // relation-scoped self-connect rather than a blanket rule on the
106
+ // target: `connect: 'ALL'` on User would let this role attach ANY
107
+ // user, which is far wider than what a self-owned create needs.
108
+ const createRule = rules?.create;
109
+ const ownerField = typeof createRule === 'object' && createRule
110
+ ? createRule.setUserIdField
111
+ : undefined;
112
+ const fix = ownerField === col
113
+ ? `'${col}' is server-set via setUserIdField, but the relationship it creates still needs a connect rule. Scope it to the caller with '${modelKey}.${role}.relations.${r.field}.connect: {conditions: {id: "$USER_ID"}}' — prefer that over a blanket '${r.model}.${role}.connect', which would allow connecting to any ${r.model}.`
114
+ : `Declare '${r.model}.${role}.connect' (or 'ALL'), or scope it to this relation with '${modelKey}.${role}.relations.${r.field}.connect'.`;
103
115
  if (settableOnCreate && r.isRequired) {
104
116
  issues.push({
105
117
  level: 'error',
@@ -59,6 +59,8 @@ Shape: `PermissionsConfig[Model][Role][action]`, where each `action` value is ei
59
59
 
60
60
  > `aggregate` is **not** a supported permission action yet — see [limitations.md](./limitations.md).
61
61
 
62
+ > **Migrating from row-level security (Supabase/Postgres RLS)?** Read this section before transcribing your policies. An RLS `INSERT` policy routinely checks a *joined* table (`album.owner = auth.uid()`), so the natural first attempt is `create: {conditions: {album: canEditAlbum}}` — **that is a boot error here.** A `create` condition may only reference the created model's **own scalar fields**; everything that crosses a relation belongs in the [`connect`](#the-connect-action--authorizes-every-relationship-a-write-establishes) action instead. The split is deliberate: "may this row exist with these values" and "may this caller attach it to *that* row" are separate questions, and the second one is checked against the target's rule.
63
+
62
64
  ### The `connect` action — authorizes every relationship a write establishes
63
65
 
64
66
  A `create` condition judges the created model's **own scalar fields only** — it never reaches across a relation. All relationship authorization lives in the `connect` action. Whenever a **write** links a row to model `T` — a `create` that sets the association, **or an `update` that re-points a foreign key** — the framework checks **`T`'s `connect` rule**, a **dedicated** action separate from reads (*being able to see a record is not permission to associate it* — seeing a team ≠ being allowed to join it). This applies however the link is supplied:
@@ -128,7 +130,18 @@ So a minimal USER config often needs just `findMany`, `create`, `update`, `delet
128
130
 
129
131
  - **`/// @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.
130
132
  - **`/// @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.
131
- - **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership).
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:
134
+
135
+ ```ts
136
+ Album: {
137
+ USER: {
138
+ create: {setUserIdField: 'ownerId'},
139
+ relations: {owner: {connect: {conditions: {id: '$USER_ID'}}}},
140
+ },
141
+ },
142
+ ```
143
+
144
+ Prefer that over `User.USER.connect: 'ALL'`, which would let the role attach *any* user — much wider than a self-owned create needs.
132
145
 
133
146
  > For the generated request-body DTOs — their structure, and the ways to **remove a writable field** (`OmitType`, `/// @NoWrite`), including how to handle per-role differences — see **[dtos.md](./dtos.md)**. (The old per-role `restrictedFields` strip has been removed — see the migration note there.)
134
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.125",
3
+ "version": "0.1.126",
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",