@appxdigital/appx-core 0.1.121-beta.12 → 0.1.121-beta.13

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
@@ -119,13 +119,15 @@ async update(@Param('id') id: string, @Body() data: Update<Model>Dto) { return s
119
119
 
120
120
  > ## ⚠️ Relationship authorization moved to `connect` — this WILL break configs; the app refuses to boot until migrated
121
121
  >
122
- > **Every foreign-key reference a `create` establishes is now authorized by the target model's `connect` rule** — whether supplied as a nested `connect`, a nested `create`, **or a plain scalar foreign key** (`ownerId: 7` is checked against `User.connect`, no bypass). A **`create` condition now judges the created model's own scalar fields only** — it must NOT reach across a relation. The one thing not re-checked is the foreign key Prisma auto-fills for a child nested inside its parent's `create` (the parent already authorized itself).
122
+ > **Every foreign-key reference a write establishes is now authorized by the target model's `connect` rule** — on a `create` (nested `connect`, nested `create`, **or a plain scalar FK** like `ownerId: 7`) **and on an `update` that re-points a foreign key** (`{ teacherId: 5 }` / `{ teacherId: { set: 5 } }`). A **`create` condition now judges the created model's own scalar fields only** — it must NOT reach across a relation. The one thing not re-checked is the foreign key Prisma auto-fills for a child nested inside its parent's `create` (the parent already authorized itself).
123
123
  >
124
- > **The framework validates this at boot.** If a model a role can `create` has a **required** foreign key whose target has **no `connect` rule** for that role, **the app refuses to start** (it would always `403`); an optional FK logs a warning; a `create` condition that references a relation is also a boot error.
124
+ > **The framework validates this at boot.** If a model a role can create **or update** can set a foreign key whose target has **no `connect` rule** for that role, **the app refuses to start** (setting it would `403`). A required-on-create FK reports as such; a `@NoWrite`/`@Role(none)` FK column is exempt (it can't be written); a `create` condition that references a relation, or a relation-scoped `connect` on a non-writable field, are also boot errors.
125
+ >
126
+ > **`restrictedFields` removed.** The per-role body-strip option on a permission action (`restrictedFields: ['x']`) is gone. Migrate: mark a never-client-writable column `/// @NoWrite` (or `/// @Role(none)`); drop a field from one endpoint with `OmitType`; force an owner id with `setUserIdField`; and for a genuinely per-role write difference, use a dedicated endpoint (generic CRUD can't vary a field by role). See [`docs/dtos.md`](../docs/dtos.md#per-role-differences).
125
127
  >
126
128
  > **Required migration for existing projects:**
127
129
  > 1. **Move relationship checks out of `create` conditions and into `connect` rules.** A rule like `ExamQuestion.create: { conditions: { exam: { course: {...} } } }` must become `Course.connect: { conditions: {...} }` (or `Exam.connect`), reached via the FK on the create. A `create` condition may only constrain the model's **own** columns (e.g. `{ ownerId: $USER_ID }`, which stays valid and is still checked).
128
- > 2. **Add a `connect` rule to every model referenced by a foreign key on a create you allow.** For unrestricted roles that already have `create: 'ALL'`, add `connect: 'ALL'`. Start the app — the boot error lists every missing `<Model>.<Role>.connect` by name; add each until it starts.
130
+ > 2. **Add a `connect` rule to every model referenced by a foreign key on a create you allow.** For unrestricted roles that already have `create: 'ALL'`, add `connect: 'ALL'`. Start the app — the boot error lists every missing `<Model>.<Role>.connect` by name; add each until it starts. If a *specific* relation needs its own (stricter) rule — "a class's teacher must be a teacher" — declare it on the source instead: `Source[Role].relations.<field>.connect`. That also satisfies the requirement, so the target model then needs no `connect` rule for that reference.
129
131
  > 3. Because one `connect` rule serves every relation that points at a model (it is keyed on `[model][role]`, not per-relation), make its conditions the **union** of what any referencing relation legitimately needs; enforce anything finer in an explicit endpoint.
130
132
  >
131
133
  > **For AI agents:** this is a behaviour restriction on a production app. Before deploying, tell the developer that (a) any create that sets a foreign key to a model without a `connect` rule now fails — and the app won't boot until those rules exist, and (b) any `create` condition that referenced a relation no longer enforces it there and must be re-expressed as a `connect` rule. Do not proceed silently. The boot-time error message names exactly what to add. See [`docs/permissions.md`](../docs/permissions.md#the-connect-action--authorizes-every-relationship-a-create-establishes).
package/PRERELEASES.md CHANGED
@@ -31,6 +31,36 @@ Prereleases are excluded from normal semver range resolution, so a project on
31
31
 
32
32
  ---
33
33
 
34
+ ## [0.1.121-beta.13] — unreleased
35
+
36
+ **Library.** **Relation-scoped `connect` rules.** A `connect` rule can now be
37
+ declared on the **source** model, keyed by the relation field
38
+ (`relations: { teacher: { connect: { conditions: {...} } } }`), to constrain who
39
+ may be attached *through that relation* — something the per-target-model
40
+ `connect` rule can't express (e.g. "a class's teacher must be a teacher"). When
41
+ both the relation rule and the target's `connect` rule exist they are **ANDed**
42
+ (the relation rule only strengthens); when only the relation rule exists it
43
+ stands alone, so the target model needs no `connect` rule. The boot validator
44
+ accepts either placement. Additive and backward-compatible — no `relations` block
45
+ means unchanged behaviour. See `docs/permissions.md`.
46
+
47
+ **Connect authorization now also covers `update`.** Re-pointing a foreign key on
48
+ an `update` (`{ teacherId: 5 }` or `{ teacherId: { set: 5 } }`) is authorized by
49
+ the same `connect` rules as `create` — previously the update path set foreign
50
+ keys with no connect check. The `relations` config is shared across create and
51
+ update.
52
+
53
+ **Boot validator is now strict about all settable FKs.** Any foreign key a role
54
+ can set on create **or** update whose target lacks a `connect` rule is a boot
55
+ **error** (previously optional/update FKs were warnings). `@NoWrite` / `@Role(none)`
56
+ columns are exempt (never client-writable), and a relation-scoped `connect` on a
57
+ non-writable field is flagged as a contradiction.
58
+
59
+ **`restrictedFields` removed (breaking).** The per-role body-strip option on a
60
+ permission action is gone; the `CoreController` no longer strips fields. Migrate
61
+ to `/// @NoWrite` / `OmitType` / `setUserIdField` / a dedicated endpoint — see the
62
+ `[0.1.121]` migration in `CHANGELOG.md` and `docs/dtos.md`.
63
+
34
64
  ## [0.1.121-beta.12] — unreleased
35
65
 
36
66
  **Library.** **Relationship authorization moved entirely to the `connect` action, with boot-time config validation.** Two fixes from an adopter report plus the model change:
@@ -48,15 +48,31 @@ export type FieldConditions<M extends ModelKey<RuntimeClient>, A extends Methods
48
48
  export interface ActionPermission<M extends ModelKey<RuntimeClient> = ModelKey<RuntimeClient>, A extends MethodsWithWhere<RuntimeClient, M> = MethodsWithWhere<RuntimeClient, M>> {
49
49
  conditions: FieldConditions<M, A> | FieldConditions<M, A>[];
50
50
  setUserIdField?: string;
51
- restrictedFields?: string[];
52
51
  }
52
+ /**
53
+ * A relation-scoped `connect` rule, declared on the SOURCE model and keyed by
54
+ * the relation field. It authorizes attaching a target through *that specific
55
+ * relation* (e.g. who may be a class's `teacher`). When the target model also
56
+ * declares a `connect` rule, the two are ANDed (the relation rule can only
57
+ * strengthen, never weaken, the target's rule). When only the relation rule
58
+ * exists, it stands alone — the target model then needs no `connect` rule.
59
+ */
60
+ export interface RelationConnectRule {
61
+ connect: 'ALL' | {
62
+ conditions?: unknown | unknown[];
63
+ };
64
+ }
65
+ export type RelationConnectMap = {
66
+ [relationField: string]: RelationConnectRule;
67
+ };
53
68
  export type RolePermissions<M extends ModelKey<RuntimeClient> = ModelKey<RuntimeClient>> = {
54
69
  [A in MethodsWithWhere<RuntimeClient, M>]?: 'ALL' | ActionPermission<M, A>;
70
+ } & {
71
+ relations?: RelationConnectMap;
55
72
  } & {
56
73
  [custom: string]: 'ALL' | {
57
74
  conditions?: unknown | unknown[];
58
75
  setUserIdField?: string;
59
- restrictedFields?: string[];
60
76
  };
61
77
  };
62
78
  export type ModelPermissions<M extends ModelKey<RuntimeClient> = ModelKey<RuntimeClient>> = {
@@ -1 +1 @@
1
- {"version":3,"file":"permissionsConfigTypes.d.ts","sourceRoot":"","sources":["../../../src/common/config/permissionsConfigTypes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"permissionsConfigTypes.d.ts","sourceRoot":"","sources":["../../../src/common/config/permissionsConfigTypes.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"core.controller.d.ts","sourceRoot":"","sources":["../../../src/modules/core/core.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAI3C,OAAO,EAAC,kBAAkB,EAAC,MAAM,yCAAyC,CAAC;AAS3E,8BACsB,cAAc,CAAC,CAAC;IAM9B,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;IAL9C,MAAM,KAAK,UAAU,IAAI,MAAM,CAE9B;IAED,SAAS,aACc,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;IAK9C,SAAS,CAAC,QAAQ,CAAC,kBAAkB,EAAG,kBAAkB,CAAC;IAIrD,OAAO;IAMP,OAAO,CAAc,EAAE,EAAE,MAAM;IAM/B,MAAM,CAAS,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAC;IAuBtD,MAAM,CAAc,EAAE,EAAE,MAAM,EAAU,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAsBxD,MAAM,CAAc,EAAE,EAAE,MAAM;CAGvC"}
1
+ {"version":3,"file":"core.controller.d.ts","sourceRoot":"","sources":["../../../src/modules/core/core.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAI3C,OAAO,EAAC,kBAAkB,EAAC,MAAM,yCAAyC,CAAC;AAQ3E,8BACsB,cAAc,CAAC,CAAC;IAM9B,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;IAL9C,MAAM,KAAK,UAAU,IAAI,MAAM,CAE9B;IAED,SAAS,aACc,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;IAK9C,SAAS,CAAC,QAAQ,CAAC,kBAAkB,EAAG,kBAAkB,CAAC;IAIrD,OAAO;IAMP,OAAO,CAAc,EAAE,EAAE,MAAM;IAM/B,MAAM,CAAS,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAC;IAiBtD,MAAM,CAAc,EAAE,EAAE,MAAM,EAAU,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAMxD,MAAM,CAAc,EAAE,EAAE,MAAM;CAGvC"}
@@ -39,33 +39,13 @@ let CoreController = exports.CoreController = CoreController_1 = class CoreContr
39
39
  const model = this.constructor.entityName;
40
40
  const rolePermissions = this.permissionsService.getPermissionsConfig()[model]?.[role];
41
41
  const actionPermission = rolePermissions?.['create'];
42
- if (typeof actionPermission !== 'string' && actionPermission) {
43
- // Strip restricted fields first, then force the server-owned user id
44
- // (so setUserIdField cannot be clobbered by a restricted-field strip).
45
- for (const field of (actionPermission.restrictedFields ?? [])) {
46
- delete data[field];
47
- }
48
- if (actionPermission.setUserIdField) {
49
- data[actionPermission.setUserIdField] = user.id;
50
- }
42
+ if (typeof actionPermission !== 'string' && actionPermission?.setUserIdField) {
43
+ // Force the server-owned user id (so a client can't forge ownership).
44
+ data[actionPermission.setUserIdField] = user.id;
51
45
  }
52
46
  return this.service.create(data);
53
47
  }
54
48
  async update(id, data) {
55
- const user = nestjs_request_context_1.RequestContext.currentContext.req.user;
56
- const role = user?.role || 'GUEST';
57
- const model = this.constructor.entityName;
58
- const rolePermissions = this.permissionsService.getPermissionsConfig()[model]?.[role];
59
- // The action key is 'updateMany' (matches @Permission('updateMany') and
60
- // the proxy) — reading 'update' here meant restrictedFields never applied.
61
- const actionPermission = rolePermissions?.['updateMany'];
62
- let restrictedFields = [];
63
- if (typeof actionPermission !== 'string' && actionPermission?.restrictedFields) {
64
- restrictedFields = actionPermission.restrictedFields;
65
- }
66
- for (const field of restrictedFields) {
67
- delete data[field];
68
- }
69
49
  return this.service.updateById(id, data);
70
50
  }
71
51
  async delete(id) {
@@ -19,11 +19,13 @@ export interface RelationMeta {
19
19
  referencingColumn?: string;
20
20
  isRequired: boolean;
21
21
  kind: RelationKind;
22
+ /** False when the scalar FK is @NoWrite / @Role(none) (never client-writable). Defaults to writable. */
23
+ fkWritable?: boolean;
22
24
  }
23
25
  /** Relations per model, keyed by the lower-cased model name. */
24
26
  export type SchemaRelations = Record<string, RelationMeta[]>;
25
27
  export type IssueLevel = 'error' | 'warning';
26
- export type IssueCode = 'relation-in-create-condition' | 'missing-connect-required' | 'missing-connect-optional';
28
+ export type IssueCode = 'relation-in-create-condition' | 'missing-connect-required' | 'missing-connect' | 'connect-on-unwritable' | 'unknown-relation';
27
29
  export interface ValidationIssue {
28
30
  level: IssueLevel;
29
31
  code: IssueCode;
@@ -1 +1 @@
1
- {"version":3,"file":"permissions-validator.d.ts","sourceRoot":"","sources":["../../src/prisma/permissions-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,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;CACtB;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,0BAA0B,CAAC;AAEjC,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,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,EAC3C,SAAS,EAAE,eAAe,GAC3B,eAAe,EAAE,CA8DnB"}
1
+ {"version":3,"file":"permissions-validator.d.ts","sourceRoot":"","sources":["../../src/prisma/permissions-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,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,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,EAC3C,SAAS,EAAE,eAAe,GAC3B,eAAe,EAAE,CAgHnB"}
@@ -53,7 +53,11 @@ function validatePermissionsConfig(config, relations) {
53
53
  const rules = byModel[targetModel.toLowerCase()]?.[role];
54
54
  return !!rules && rules.connect !== undefined && rules.connect !== null;
55
55
  };
56
+ // A relation-scoped connect on the source (creating) model authorizes that
57
+ // relation on its own — the target model then needs no connect rule.
58
+ const hasRelationConnect = (sourceRules, field) => !!sourceRules?.relations?.[field]?.connect;
56
59
  const canCreate = (rules) => !!rules && (rules.create !== undefined || rules.createMany !== undefined);
60
+ const canUpdate = (rules) => !!rules && (rules.update !== undefined || rules.updateMany !== undefined);
57
61
  for (const modelKey of Object.keys(config || {})) {
58
62
  const roles = config[modelKey] || {};
59
63
  const rels = relations[modelKey.toLowerCase()] || [];
@@ -61,7 +65,9 @@ function validatePermissionsConfig(config, relations) {
61
65
  const relationFieldNames = new Set(rels.map((r) => r.field));
62
66
  for (const role of Object.keys(roles)) {
63
67
  const rules = roles[role];
64
- if (!canCreate(rules))
68
+ const creatable = canCreate(rules);
69
+ const updatable = canUpdate(rules);
70
+ if (!creatable && !updatable)
65
71
  continue;
66
72
  const createRule = rules.create ?? rules.createMany;
67
73
  const conditions = createRule && typeof createRule === 'object' ? createRule.conditions : undefined;
@@ -76,28 +82,67 @@ function validatePermissionsConfig(config, relations) {
76
82
  });
77
83
  }
78
84
  }
85
+ // Every foreign key a write can SET must have a connect rule (target
86
+ // or relation-scoped) — otherwise setting it is denied at runtime.
87
+ // A @NoWrite / @Role(none) column is never settable.
79
88
  for (const r of belongsTo) {
80
- if (hasConnectRule(r.model, role))
89
+ const col = r.referencingColumn;
90
+ const writable = r.fkWritable !== false; // not @NoWrite / @Role(none)
91
+ const settableOnCreate = writable && creatable;
92
+ const settableOnUpdate = writable && updatable;
93
+ const relRule = hasRelationConnect(rules, r.field);
94
+ if (!settableOnCreate && !settableOnUpdate) {
95
+ // A relation-scoped connect rule for a field that can't be
96
+ // written is contradictory — flag it rather than silently ignore.
97
+ if (relRule) {
98
+ issues.push({
99
+ level: 'error',
100
+ code: 'connect-on-unwritable',
101
+ model: modelKey,
102
+ role,
103
+ message: `${modelKey}.${role}.relations.${r.field}.connect is set, but its foreign key '${col}' is not writable (@NoWrite / @Role(none)). Make the field writable or remove the relation rule.`,
104
+ });
105
+ }
81
106
  continue;
82
- if (r.isRequired) {
107
+ }
108
+ if (relRule || hasConnectRule(r.model, role))
109
+ continue;
110
+ const fix = `Declare '${r.model}.${role}.connect' (or 'ALL'), or scope it to this relation with '${modelKey}.${role}.relations.${r.field}.connect'.`;
111
+ if (settableOnCreate && r.isRequired) {
83
112
  issues.push({
84
113
  level: 'error',
85
114
  code: 'missing-connect-required',
86
115
  model: modelKey,
87
116
  role,
88
- message: `${modelKey}.${role} can create, but its required foreign key '${r.referencingColumn}' → ${r.model} has no 'connect' rule for ${role}. Every create sets it, so the create is always denied. Declare '${r.model}.${role}.connect' (or 'ALL').`,
117
+ message: `${modelKey}.${role} can create, but its required foreign key '${col}' → ${r.model} has no 'connect' rule for ${role}. Every create sets it, so the create is always denied. ${fix}`,
89
118
  });
90
119
  }
91
120
  else {
92
121
  issues.push({
93
- level: 'warning',
94
- code: 'missing-connect-optional',
122
+ level: 'error',
123
+ code: 'missing-connect',
95
124
  model: modelKey,
96
125
  role,
97
- message: `${modelKey}.${role} can create and its optional foreign key '${r.referencingColumn}' → ${r.model} has no 'connect' rule for ${role}. A payload that sets it is denied. Declare '${r.model}.${role}.connect' if that association is expected.`,
126
+ message: `${modelKey}.${role} can set foreign key '${col}' → ${r.model} (on ${settableOnCreate ? 'create' : ''}${settableOnCreate && settableOnUpdate ? '/' : ''}${settableOnUpdate ? 'update' : ''}), but ${r.model} has no 'connect' rule for ${role}, so any write that sets it is denied. ${fix}`,
98
127
  });
99
128
  }
100
129
  }
130
+ // A `relations` key that doesn't name a real relation of this model is
131
+ // almost certainly a typo — it silently does nothing.
132
+ const relationsMap = rules.relations;
133
+ if (relationsMap && typeof relationsMap === 'object') {
134
+ for (const relField of Object.keys(relationsMap)) {
135
+ if (!relationFieldNames.has(relField)) {
136
+ issues.push({
137
+ level: 'warning',
138
+ code: 'unknown-relation',
139
+ model: modelKey,
140
+ role,
141
+ message: `${modelKey}.${role}.relations.${relField} does not name a relation of ${modelKey} — it has no effect. Check the field name.`,
142
+ });
143
+ }
144
+ }
145
+ }
101
146
  }
102
147
  }
103
148
  return issues;
@@ -122,13 +122,13 @@ export declare class PrismaService {
122
122
  */
123
123
  private enforceCreateConditions;
124
124
  /**
125
- * Authorizes every relation a `create` payload establishes. Relationship
126
- * authorization lives entirely in the `connect` action — create conditions
127
- * judge a model's own scalar fields only.
125
+ * Authorizes every relation a write establishes. Relationship authorization
126
+ * lives entirely in the `connect` action — create conditions judge a model's
127
+ * own scalar fields only.
128
128
  *
129
- * Runs on the create path only — `updateMany` / `createMany` (what update /
130
- * createMany resolve to) accept scalar data, so relation writes reach Prisma
131
- * solely via `create()`.
129
+ * Runs on create AND update: a `create` can nest relation writes or set raw
130
+ * FKs; an `updateMany` (scalar-only) can re-point a raw FK. Both go through
131
+ * the same `connect` authorization.
132
132
  *
133
133
  * - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
134
134
  * - `relation: { connect }` → the target's `connect` rule. `connect` is a
@@ -144,11 +144,19 @@ export declare class PrismaService {
144
144
  private enforceNestedWrites;
145
145
  /** Authorizes each operator inside one relation's nested-write object. */
146
146
  private enforceNestedRelationOps;
147
- /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's target. */
147
+ /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's field + target. */
148
148
  private foreignKeyColumn;
149
149
  /** The child's scalar FK column pointing back to the parent it's nested under (trusted). */
150
150
  private backReferenceFk;
151
- /** A `connect` requires an explicit `connect` permission on the target model. */
151
+ /**
152
+ * Authorizes attaching `relatedModel` (identified by `where`) through the
153
+ * `field` relation on `sourceModel`. Two rules can apply, evaluated against
154
+ * the candidate target row:
155
+ * - a relation-scoped rule on the source (`sourceModel[role].relations[field].connect`)
156
+ * - the target model's own rule (`relatedModel[role].connect`)
157
+ * When both exist they are ANDed (the relation rule can only strengthen).
158
+ * When only one exists it stands alone; when neither exists, default-deny.
159
+ */
152
160
  private assertConnectAllowed;
153
161
  /**
154
162
  * True only in an explicit local-development environment. Deliberately NOT
@@ -1 +1 @@
1
- {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
@@ -40,12 +40,15 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
40
40
  const relations = {};
41
41
  for (const model of Object.keys(this.fieldConfigs)) {
42
42
  const relationFields = this.fieldConfigs[model].relationFields || {};
43
+ const noWrite = this.fieldConfigs[model].noWriteFields || [];
43
44
  relations[model] = Object.keys(relationFields).map((field) => ({
44
45
  field,
45
46
  model: relationFields[field].model,
46
47
  referencingColumn: relationFields[field].referencingColumn,
47
48
  isRequired: relationFields[field].isRequired,
48
49
  kind: relationFields[field].relation,
50
+ // The scalar FK is client-writable unless the column is @NoWrite / @Role(none).
51
+ fkWritable: !noWrite.includes(relationFields[field].referencingColumn),
49
52
  }));
50
53
  }
51
54
  const issues = (0, permissions_validator_1.validatePermissionsConfig)(this.permissionsConfig, relations);
@@ -128,12 +131,17 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
128
131
  // Conditions can't be pushed into a WHERE on insert, so
129
132
  // validate the incoming data satisfies them before insert.
130
133
  await this.enforceCreateConditions(String(propKey), userRole, params, user, methodKey.toString());
131
- // Authorize any nested relation writes (create/connect) in
132
- // the payload; reject every other nested operator.
134
+ // Authorize any relation the write establishes (nested
135
+ // create/connect, or a raw foreign key).
133
136
  await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
134
137
  }
135
138
  else {
136
139
  params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
140
+ // An update can re-point a foreign key (scalar column);
141
+ // authorize those changes with the same connect rules as create.
142
+ if (['update', 'updateMany'].includes(methodKey.toString())) {
143
+ await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
144
+ }
137
145
  }
138
146
  }
139
147
  else {
@@ -208,6 +216,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
208
216
  const fieldTypes = {};
209
217
  const scalarFields = [];
210
218
  const relationFields = {};
219
+ const noWriteFields = [];
211
220
  for (const field of fields) {
212
221
  let field_name = field.name;
213
222
  const fieldType = field.type;
@@ -217,6 +226,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
217
226
  if (roleMatch) {
218
227
  fieldConfig[field_name] = roleMatch[1].split(',').map((role) => role.trim());
219
228
  }
229
+ // Fields excluded from write DTOs — never client-writable.
230
+ if (/@NoWrite\b/.test(commentPart) || /@Role\(\s*none\s*\)/i.test(commentPart)) {
231
+ noWriteFields.push(field_name);
232
+ }
220
233
  }
221
234
  allFields.push(field_name);
222
235
  fieldTypes[field_name] = fieldType;
@@ -245,6 +258,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
245
258
  fieldTypes,
246
259
  scalarFields,
247
260
  relationFields,
261
+ noWriteFields,
248
262
  };
249
263
  }
250
264
  }
@@ -518,13 +532,13 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
518
532
  }
519
533
  }
520
534
  /**
521
- * Authorizes every relation a `create` payload establishes. Relationship
522
- * authorization lives entirely in the `connect` action — create conditions
523
- * judge a model's own scalar fields only.
535
+ * Authorizes every relation a write establishes. Relationship authorization
536
+ * lives entirely in the `connect` action — create conditions judge a model's
537
+ * own scalar fields only.
524
538
  *
525
- * Runs on the create path only — `updateMany` / `createMany` (what update /
526
- * createMany resolve to) accept scalar data, so relation writes reach Prisma
527
- * solely via `create()`.
539
+ * Runs on create AND update: a `create` can nest relation writes or set raw
540
+ * FKs; an `updateMany` (scalar-only) can re-point a raw FK. Both go through
541
+ * the same `connect` authorization.
528
542
  *
529
543
  * - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
530
544
  * - `relation: { connect }` → the target's `connect` rule. `connect` is a
@@ -561,10 +575,12 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
561
575
  continue; // ordinary scalar
562
576
  if (key === trustedFk)
563
577
  continue; // back-reference to the nesting parent — trusted
564
- const value = row[key];
578
+ let value = row[key];
579
+ if (value && typeof value === 'object' && 'set' in value)
580
+ value = value.set; // update form: { set: x }
565
581
  if (value === undefined || value === null)
566
582
  continue;
567
- await this.assertConnectAllowed(fk.model, { [fk.foreignKey]: value }, user, userRole, key);
583
+ await this.assertConnectAllowed(modelName, fk.field, fk.model, { [fk.foreignKey]: value }, user, userRole);
568
584
  }
569
585
  }
570
586
  }
@@ -589,18 +605,18 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
589
605
  }
590
606
  else {
591
607
  for (const target of items) {
592
- await this.assertConnectAllowed(relatedModel, target, user, userRole, field);
608
+ await this.assertConnectAllowed(parentModel, field, relatedModel, target, user, userRole);
593
609
  }
594
610
  }
595
611
  }
596
612
  }
597
- /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's target. */
613
+ /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's field + target. */
598
614
  foreignKeyColumn(modelName, column) {
599
615
  const rels = this.fieldConfigs[modelName.toLowerCase()]?.relationFields || {};
600
616
  for (const key of Object.keys(rels)) {
601
617
  const r = rels[key];
602
618
  if (r.relation === 'belongsTo' && r.referencingColumn === column && r.foreignKey) {
603
- return { model: r.model, foreignKey: r.foreignKey, relationName: r.relationName };
619
+ return { field: key, model: r.model, foreignKey: r.foreignKey };
604
620
  }
605
621
  }
606
622
  return null;
@@ -620,25 +636,43 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
620
636
  }
621
637
  return candidates.length === 1 ? candidates[0].referencingColumn : undefined;
622
638
  }
623
- /** A `connect` requires an explicit `connect` permission on the target model. */
624
- async assertConnectAllowed(relatedModel, where, user, userRole, field) {
639
+ /**
640
+ * Authorizes attaching `relatedModel` (identified by `where`) through the
641
+ * `field` relation on `sourceModel`. Two rules can apply, evaluated against
642
+ * the candidate target row:
643
+ * - a relation-scoped rule on the source (`sourceModel[role].relations[field].connect`)
644
+ * - the target model's own rule (`relatedModel[role].connect`)
645
+ * When both exist they are ANDed (the relation rule can only strengthen).
646
+ * When only one exists it stands alone; when neither exists, default-deny.
647
+ */
648
+ async assertConnectAllowed(sourceModel, field, relatedModel, where, user, userRole) {
625
649
  const normalizedName = relatedModel.toLowerCase().trim();
626
650
  const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
627
651
  if (exposedModels.includes(normalizedName))
628
652
  return;
629
- const connectPerm = this.normalizedPermissionsConfig[normalizedName]?.[userRole]?.['connect'];
630
- if (!connectPerm) {
631
- throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): role ${userRole} has no 'connect' permission on ${relatedModel}.`);
653
+ const relationRule = this.normalizedPermissionsConfig[sourceModel.toLowerCase().trim()]?.[userRole]?.['relations']?.[field]?.connect;
654
+ const targetRule = this.normalizedPermissionsConfig[normalizedName]?.[userRole]?.['connect'];
655
+ if (relationRule === undefined && targetRule === undefined) {
656
+ throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): role ${userRole} has no 'connect' permission. ` +
657
+ `Declare it on ${relatedModel}.${userRole}.connect, or scope it to this relation with ${sourceModel}.${userRole}.relations.${field}.connect.`);
658
+ }
659
+ // Collect the conditions each applicable rule imposes; an 'ALL' or
660
+ // condition-less rule constrains nothing. The target must satisfy every
661
+ // collected condition (AND — the relation rule strengthens the target's).
662
+ const conditionSets = [];
663
+ for (const rule of [relationRule, targetRule]) {
664
+ if (rule === undefined || rule === 'ALL')
665
+ continue;
666
+ const conditions = rule.conditions;
667
+ if (conditions) {
668
+ conditionSets.push(PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user));
669
+ }
632
670
  }
633
- if (connectPerm === 'ALL')
634
- return;
635
- const conditions = connectPerm.conditions;
636
- const resolved = conditions
637
- ? PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user)
638
- : {};
671
+ if (conditionSets.length === 0)
672
+ return; // every applicable rule is unconditional
639
673
  const delegate = relatedModel.charAt(0).toLowerCase() + relatedModel.slice(1);
640
674
  const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
641
- const found = await client[delegate].findFirst({ where: { AND: [where || {}, resolved] } });
675
+ const found = await client[delegate].findFirst({ where: { AND: [where || {}, ...conditionSets] } });
642
676
  if (!found) {
643
677
  throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): the target does not satisfy the 'connect' permission conditions for role ${userRole}.`);
644
678
  }
package/docs/README.md CHANGED
@@ -5,7 +5,7 @@ Authoritative usage documentation for `@appxdigital/appx-core`. **This folder is
5
5
  ## Contents
6
6
 
7
7
  - **[permissions.md](./permissions.md)** — the authorization model: the `@Permission` and `@ExposeModels` decorators, the `permissions.config.ts` shape, roles/actions, row-level `conditions`, field-level control, action fallbacks, and the `GUEST` (unauthenticated) flow.
8
- - **[dtos.md](./dtos.md)** — the generated CRUD validation DTOs: their two-file structure, **how to remove a field** (`OmitType` / `@NoWrite` / `restrictedFields`), how to add validation, and why nested relation writes don't belong on generic CRUD.
8
+ - **[dtos.md](./dtos.md)** — the generated CRUD validation DTOs: their two-file structure, **how to remove a field** (`OmitType` / `@NoWrite`), how to add validation, and why nested relation writes don't belong on generic CRUD.
9
9
  - **[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.
10
10
  - **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
11
11
  - **[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.
package/docs/dtos.md CHANGED
@@ -42,13 +42,12 @@ A scalar field is **included** unless it is:
42
42
 
43
43
  ## How to remove a field
44
44
 
45
- There are three ways, at different scopes. Pick the narrowest one that fits.
45
+ There are two ways, at different scopes. Pick the narrowest one that fits.
46
46
 
47
47
  | Mechanism | Scope | Where you declare it |
48
48
  |---|---|---|
49
49
  | `OmitType(GeneratedDto, ['field'])` | this one endpoint's DTO | the hand-owned `*.dto.ts` |
50
50
  | `/// @NoWrite` (or `/// @Role(none)`) | every generated DTO, every role | the Prisma schema |
51
- | `restrictedFields: ['field']` | a specific role + action (runtime strip) | `permissions.config.ts` |
52
51
 
53
52
  ### 1. `OmitType` — remove a field for one endpoint
54
53
 
@@ -80,17 +79,11 @@ model Task {
80
79
  }
81
80
  ```
82
81
 
83
- ### 3. `restrictedFields` — remove a field per role
82
+ ### Per-role differences
84
83
 
85
- Strip specific keys from the body for a given role + action, at runtime, from `permissions.config.ts`:
84
+ 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.
86
85
 
87
- ```ts
88
- Task: {
89
- USER: { create: { conditions: { ... }, restrictedFields: ['status'] } },
90
- }
91
- ```
92
-
93
- Note: `restrictedFields` **strips silently** (no `400`), and only affects fields that are present in the DTO to begin with. See [permissions.md](./permissions.md#field-level-control).
86
+ > **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.
94
87
 
95
88
  ---
96
89
 
@@ -46,12 +46,12 @@ Generated CRUD accepts nested `create`/`connect` **one level deep only** — a `
46
46
 
47
47
  ## Sharp edges to know about
48
48
 
49
- - **`restrictedFields` strips silently.** Listed fields are removed from the request body with no `400`. It only affects fields that are present in the generated DTO (not `/// @NoWrite` ones). Decide per model whether silent stripping is acceptable.
49
+ - **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
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>`.
51
51
  - **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
52
  - **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
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.
54
- - **`connect` is one rule per target model, not per relation.** Every foreign key that points at model `T` — from any relation, on any creating model — is authorized by the single `T.connect` rule for the role. You can't say "attach a project as a task's parent (members OK)" but "attach a project for a membership (owner only)" with different `connect` conditions; both use `Project.connect`. Make the rule the union of what any referencing relation needs, and enforce finer distinctions in an explicit endpoint.
54
+ - **`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
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.
56
56
 
57
57
  ---
@@ -59,9 +59,9 @@ 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
- ### The `connect` action — authorizes every relationship a create establishes
62
+ ### The `connect` action — authorizes every relationship a write establishes
63
63
 
64
- 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 `create` payload establishes a link to model `T`, 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:
64
+ 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:
65
65
 
66
66
  | Payload | Authorized by |
67
67
  |---|---|
@@ -79,11 +79,38 @@ Team: {
79
79
  }
80
80
  ```
81
81
 
82
- The `connect` rule's conditions are evaluated against the candidate record. **Default-deny, no fallback:** if the target model has no `connect` rule, the association is refused — a raw FK cannot bypass it. The one exception is the foreign key Prisma fills in automatically for a child created *inside* its parent's payload: that back-reference is trusted, because the parent authorized itself.
82
+ The `connect` rule's conditions are evaluated against the candidate record. **Default-deny, no fallback:** if the target model has no `connect` rule, the association is refused — a raw FK cannot bypass it, on create **or** update. The one exception is the foreign key Prisma fills in automatically for a child created *inside* its parent's payload: that back-reference is trusted, because the parent authorized itself.
83
83
 
84
84
  > **Boot validation.** Because a raw FK now needs a `connect` rule, the framework validates the config against the schema **at startup**. If a model a role can `create` has a **required** foreign key whose target lacks a `connect` rule for that role, the app **refuses to boot** (it would always `403`). An **optional** FK in the same situation logs a warning. A `create` condition that references a relation is also a boot error. Fix the config or the app won't start. See [dtos.md](./dtos.md#relations--nested-writes) for the nested-write allowlist (`create` / `connect` only).
85
85
 
86
- > **One `connect` rule per target model.** `connect` is keyed on `[model][role]`, so it can't vary by *which* relation references the model — e.g. "attach a project as a task's parent" and "attach a project for a membership" share one `Project.connect` rule. Make it the union of what any referencing relation needs, and enforce anything finer in an explicit endpoint.
86
+ ### Two places to declare a `connect` rule
87
+
88
+ The rule above lives on the **target** model (`Target.connect`) and governs *every* reference to that model. When that's too coarse — e.g. "a class's **teacher** must be a teacher, but a comment's **author** is anyone" — declare a **relation-scoped** rule on the **source** model, keyed by the relation field:
89
+
90
+ ```ts
91
+ Class: {
92
+ ADMIN: {
93
+ create: 'ALL',
94
+ relations: {
95
+ teacher: { connect: { conditions: { role: 'TEACHER' } } }, // who may be THIS class's teacher
96
+ },
97
+ },
98
+ },
99
+ User: {
100
+ ADMIN: { connect: 'ALL' }, // a user is referenceable elsewhere (author, assignee, …)
101
+ },
102
+ ```
103
+
104
+ The relation-scoped rule applies however the relation is supplied — `teacherId: 5` or `teacher: { connect: { id: 5 } }`. How the two rules combine:
105
+
106
+ | `Source[role].relations[field].connect` | `Target[role].connect` | Result |
107
+ |---|---|---|
108
+ | present | present | target must satisfy **both** (ANDed — the relation rule only *strengthens*, never weakens, the target rule) |
109
+ | present | absent | target must satisfy the **relation** rule; the target model needs no `connect` rule |
110
+ | absent | present | target must satisfy the **target** rule (the default) |
111
+ | absent | absent | default-deny (boot error for a required FK) |
112
+
113
+ So a relation-scoped rule can (a) add restrictions on top of the target's rule, or (b) stand alone — if you'd rather not give the target a broad `connect` (no `User.connect: 'ALL'`), put the rule on each relation that references it instead. A relation rule can never *loosen* the target's rule; both must pass when both exist.
87
114
 
88
115
  ### Action fallback chains
89
116
 
@@ -100,11 +127,10 @@ So a minimal USER config often needs just `findMany`, `create`, `update`, `delet
100
127
  ### Field-level control
101
128
 
102
129
  - **`/// @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.
103
- - **`/// @NoWrite` on a schema column** — excludes the field from generated create/update DTOs (all roles).
104
- - **`restrictedFields: ['x', 'y']` on a permission action** — strips those keys from the incoming request body for that role+action (e.g. stop a role from setting `role`/`id`). Silent strip, not a `400`.
105
- - **`setUserIdField: 'ownerId'`** — the framework sets that field to the caller's id server-side on create (so a client can't forge ownership). Applied after `restrictedFields`, so it can't be clobbered.
130
+ - **`/// @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).
106
132
 
107
- > For the generated request-body DTOs — their structure, and the full set of ways to **remove or restrict a writable field** (`OmitType`, `/// @NoWrite`, `restrictedFields`) — see **[dtos.md](./dtos.md)**.
133
+ > 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.)
108
134
 
109
135
  ---
110
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.121-beta.12",
3
+ "version": "0.1.121-beta.13",
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",