@memberjunction/core 6.1.0-edge.6 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/generic/baseEngine.d.ts +52 -1
  2. package/dist/generic/baseEngine.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.js +93 -4
  4. package/dist/generic/baseEngine.js.map +1 -1
  5. package/dist/generic/baseEntity.d.ts +173 -0
  6. package/dist/generic/baseEntity.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.js +357 -12
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/entityInfo.d.ts +461 -1
  10. package/dist/generic/entityInfo.d.ts.map +1 -1
  11. package/dist/generic/entityInfo.js +568 -6
  12. package/dist/generic/entityInfo.js.map +1 -1
  13. package/dist/generic/interfaces.d.ts +5 -0
  14. package/dist/generic/interfaces.d.ts.map +1 -1
  15. package/dist/generic/interfaces.js.map +1 -1
  16. package/dist/generic/localCacheManager.d.ts +30 -1
  17. package/dist/generic/localCacheManager.d.ts.map +1 -1
  18. package/dist/generic/localCacheManager.js +48 -1
  19. package/dist/generic/localCacheManager.js.map +1 -1
  20. package/dist/generic/providerBase.d.ts +164 -2
  21. package/dist/generic/providerBase.d.ts.map +1 -1
  22. package/dist/generic/providerBase.js +398 -23
  23. package/dist/generic/providerBase.js.map +1 -1
  24. package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
  25. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
  26. package/dist/generic/recordChangeFieldSecurity.js +279 -0
  27. package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
  28. package/dist/generic/saveEntityGraphOperation.d.ts +10 -0
  29. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  30. package/dist/generic/saveEntityGraphOperation.js +2 -1
  31. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  32. package/dist/generic/wellKnownUserSource.d.ts +70 -0
  33. package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
  34. package/dist/generic/wellKnownUserSource.js +82 -0
  35. package/dist/generic/wellKnownUserSource.js.map +1 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/package.json +3 -3
@@ -8,9 +8,9 @@ var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
10
  var EntityField_1;
11
- import { IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
11
+ import { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
12
12
  import { GetDataHooks } from './dataHooks.js';
13
- import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
13
+ import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
14
14
  import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
15
15
  import { BaseEngineRegistry } from './baseEngineRegistry.js';
16
16
  import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from './extendedTypeValue.js';
@@ -52,6 +52,57 @@ let EntityField = class EntityField {
52
52
  "real": { min: -3.402823466e+38, max: 3.402823466e+38 },
53
53
  "money": { min: -922337203685477.5808, max: 922337203685477.5807 },
54
54
  }; }
55
+ /**
56
+ * True when the source this entity was hydrated from OMITTED this field's key — most
57
+ * commonly because field-level security stripped it before the payload reached us, but
58
+ * equally for any partial hydration. The field's in-memory state (its metadata default,
59
+ * else null) is a construction artifact, not data: the save path skips not-loaded fields
60
+ * entirely (the generated procs' `ISNULL(@p, [Col])` merge then preserves the stored
61
+ * value), {@link Dirty} always reports false for them, and {@link Validate} exempts them
62
+ * from the required/null check.
63
+ *
64
+ * Deliberately DISTINCT from `_NeverSet`, which means "no set since construction," exists
65
+ * to permit the one-time write to ReadOnly fields on load, and is re-armed wholesale by
66
+ * `InnerLoad` — reusing it would conflate defaults with omissions. This flag is set only
67
+ * by the hydration paths (via {@link MarkNotLoaded}) when a source omits the key, is
68
+ * cleared by ANY explicit set (an intentional blind write to a read-denied field is a
69
+ * legitimate write-only update and must save), and is never present on new (unhydrated)
70
+ * entities — their fields legitimately hold metadata defaults for INSERT.
71
+ */
72
+ get NotLoaded() {
73
+ return this._NotLoaded;
74
+ }
75
+ /**
76
+ * Framework-internal: hydration paths call this for each field whose key the hydration
77
+ * source omitted. Application code should never need it — an explicit {@link Value} set
78
+ * clears the flag.
79
+ */
80
+ MarkNotLoaded() {
81
+ this._NotLoaded = true;
82
+ }
83
+ /**
84
+ * True when field-level security bars this user from supplying the field's value on INSERT,
85
+ * so the save path must omit it and let the column take its database default.
86
+ *
87
+ * Deliberately DISTINCT from {@link NotLoaded}, even though both end in "leave this
88
+ * parameter out of the SP call". `NotLoaded` means "the hydration source omitted this key"
89
+ * and carries consequences this must not: it suppresses the `_Clear` companion, exempts the
90
+ * field from required/null validation, and forces {@link Dirty} to false. A create-suppressed
91
+ * field, by contrast, holds a perfectly real value the user typed — it is simply not one they
92
+ * are permitted to supply. Conflating them would silently disarm validation on fields a user
93
+ * IS allowed to create.
94
+ *
95
+ * Set per-save by the create-path gate and cleared at the start of every save, because the
96
+ * answer depends on the acting user and the same entity object can be saved by different
97
+ * users over its lifetime.
98
+ */
99
+ get CreateSuppressed() {
100
+ return this._CreateSuppressed;
101
+ }
102
+ /** Framework-internal: set by the save path's field-security create gate. */
103
+ SetCreateSuppressed(suppressed) {
104
+ this._CreateSuppressed = suppressed;
105
+ }
55
106
  get Name() {
56
107
  return this._entityFieldInfo.Name;
57
108
  }
@@ -126,8 +177,16 @@ let EntityField = class EntityField {
126
177
  value = value.replace(/ +$/, '');
127
178
  }
128
179
  this._Value = value;
180
+ // Any explicit set means the field now holds REAL data — including a blind write to
181
+ // a read-denied field (the write-only case), which must flow to the save. Captured
182
+ // BEFORE clearing so the OldValue branch below can see it: a set onto a not-loaded
183
+ // field is an EDIT (must become dirty and reach the save), never the "initial value
184
+ // set" of record setup — the record's setup moment was the hydration that omitted
185
+ // this field.
186
+ const wasNotLoaded = this._NotLoaded;
187
+ this._NotLoaded = false;
129
188
  // in the below, we set the OldValue, but only if (a) we have never set the value before, or (b) the value or the old value is not null - which means that we are in a record setup scenario
130
- if (this._NeverSet &&
189
+ if (this._NeverSet && !wasNotLoaded &&
131
190
  (value !== null || this._OldValue !== null)) {
132
191
  // initial value set
133
192
  this._OldValue = value;
@@ -145,9 +204,11 @@ let EntityField = class EntityField {
145
204
  }
146
205
  /**
147
206
  * Returns true if the field is dirty, false otherwise. A field is considered dirty if the value is different from the old value. If the field is read only, it is never dirty.
207
+ * A {@link NotLoaded} field is never dirty either — its in-memory state is a construction
208
+ * artifact, not data, and nothing that was never loaded can have been changed.
148
209
  */
149
210
  get Dirty() {
150
- if (this.ReadOnly)
211
+ if (this.ReadOnly || this._NotLoaded)
151
212
  return false;
152
213
  else {
153
214
  const oldNull = this._OldValue === null || this.OldValue === undefined || Number.isNaN(this.OldValue); // check for NaN because sometimes we have old values that are NaN and we need to account for that
@@ -281,7 +342,13 @@ let EntityField = class EntityField {
281
342
  // no longer false-warns or throws here.
282
343
  if (!ef.ReadOnly && !ef.SkipValidation) {
283
344
  // only do validation on updatable fields and skip the special case fields defined inside the SkipValidation property (like ID/CreatedAt/UpdatedAt)
284
- if (!ef.AllowsNull && (this.Value === null || this.Value === undefined)) {
345
+ // NotLoaded exemption: a field the hydration source omitted holds null/default by
346
+ // construction and is SKIPPED by the save SQL, so the stored value is untouched —
347
+ // failing the required check here would make every partially hydrated record
348
+ // unsaveable for unrelated edits (the exact NOT-NULL breakage mode the not-loaded
349
+ // design exists to remove). Length/type checks below still apply to whatever the
350
+ // constructor state is; only the required/null check is meaningless for it.
351
+ if (!ef.AllowsNull && !this._NotLoaded && (this.Value === null || this.Value === undefined)) {
285
352
  // make sure this isn't a field that has a default value and we are inside a new record
286
353
  if (ef.DefaultValue === null || ef.DefaultValue === undefined || ef.DefaultValue.trim().length === 0) {
287
354
  // we have no default value, so this is an error
@@ -364,6 +431,8 @@ let EntityField = class EntityField {
364
431
  }
365
432
  constructor(fieldInfo, Value) {
366
433
  this._NeverSet = true;
434
+ this._NotLoaded = false;
435
+ this._CreateSuppressed = false;
367
436
  // NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
368
437
  // column is physically part of the entity and the instance must exist. Active-status is
369
438
  // asserted only when code accesses the field by name via BaseEntity.Get/Set/SetMany, never
@@ -521,6 +590,21 @@ export class BaseEntityAIActionParams {
521
590
  */
522
591
  export class BaseEntityResult {
523
592
  constructor(success, message, type) {
593
+ /**
594
+ * Set by a producer whose `Message` ALREADY renders every entry of `Errors`, so that
595
+ * {@link CompleteMessage} does not say the same thing twice.
596
+ *
597
+ * Two kinds of producer build `Message` out of `Errors`: the client-side providers, which copy
598
+ * the SERVER's `CompleteMessage` (= its errors, joined) into `Message` and then rehydrate the same
599
+ * entries into `Errors` so a form can paint the fields; and the IS-A parent-failure paths, which
600
+ * write "Failed to save parent entity … : <errors joined>". Without this flag every one of them
601
+ * read twice in `CompleteMessage`. A substring dedupe was tried and reverted (it is lossy in ways
602
+ * a reader cannot detect — see `CompleteMessage`); a producer stating the fact is exact.
603
+ *
604
+ * Only honoured when `Message` actually has text: a producer that set the flag and left `Message`
605
+ * empty is contradicting itself, and the errors are still rendered rather than lost.
606
+ */
607
+ this.MessageIncludesErrors = false;
524
608
  /**
525
609
  * A copy of the values of the entity object BEFORE the operation was performed
526
610
  */
@@ -612,8 +696,12 @@ export class BaseEntityResult {
612
696
  // or dropped depending on ARRAY ORDER; and a distinct error vanishes when its text happens to
613
697
  // appear inside the summary. Saying something twice is ugly. Silently reporting one problem
614
698
  // when there were three is the failure this whole class of bug is about, so the duplication
615
- // stays until a producer-side fix removes it at the source.
616
- if (this.Errors && this.Errors.length > 0) {
699
+ // stays UNLESS the producer states, via `MessageIncludesErrors`, that `Message` already
700
+ // renders every entry an exact fact, not a guess, and the only producer-side fix that
701
+ // removes the repeat at the source.
702
+ const messageHasText = !!this.Message && this.Message.trim().length > 0;
703
+ const errorsAlreadyInMessage = this.MessageIncludesErrors && messageHasText;
704
+ if (this.Errors && this.Errors.length > 0 && !errorsAlreadyInMessage) {
617
705
  // append
618
706
  msg = (msg ? msg + '\n' : '') + this.Errors.map(err => BaseEntityResult.ErrorText(err)).join('\n');
619
707
  }
@@ -1578,6 +1666,8 @@ export class BaseEntity {
1578
1666
  // ignoreNonExistentFields=true remains as a safety net; ownedFieldsFrom already
1579
1667
  // dropped columns that belong to another level of the IS-A chain.
1580
1668
  this.SetMany(this.ownedFieldsFrom(data), true, true, true);
1669
+ // Hydrate is a hydration entry point: keys the source omitted are not-loaded.
1670
+ this.markFieldsOmittedBySourceAsNotLoaded(data);
1581
1671
  }
1582
1672
  /**
1583
1673
  * Columns on `data` that this entity actually defines (by field name or CodeName).
@@ -1821,7 +1911,10 @@ export class BaseEntity {
1821
1911
  }, { provider: this.ProviderToUse, user: this.ActiveUser });
1822
1912
  if (!result.Success || !result.Output?.Success) {
1823
1913
  const detail = result.ErrorMessage ?? result.Output?.ErrorMessage ?? 'unknown error';
1824
- this.registerGraphFailure(detail);
1914
+ // The structured refusal rides alongside the prose so a form can paint the fields a
1915
+ // server-side ValidateAsync named — the same thing a plain save gets from the
1916
+ // GraphQL error's `extensions.validationErrors`.
1917
+ this.registerGraphFailure(detail, 'save', result.Output?.ValidationErrors);
1825
1918
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
1826
1919
  return false;
1827
1920
  }
@@ -2240,11 +2333,16 @@ export class BaseEntity {
2240
2333
  *
2241
2334
  * @param message - The failure detail.
2242
2335
  */
2243
- registerGraphFailure(message, operation = 'save') {
2336
+ registerGraphFailure(message, operation = 'save', validationErrors) {
2244
2337
  const result = new BaseEntityResult();
2245
2338
  result.Success = false;
2246
2339
  result.Type = operation === 'delete' ? 'delete' : this.IsSaved ? 'update' : 'create';
2247
2340
  result.Message = message ?? 'Entity graph operation failed';
2341
+ // Rehydrated into real ValidationErrorInfo instances so `LatestResult.Errors` reads exactly as
2342
+ // it does after a local `Validate()` refusal; `[]` when the server sent none.
2343
+ result.Errors = DeserializeValidationErrors(validationErrors);
2344
+ // `message` is the server's CompleteMessage — the same errors, already joined — so say so.
2345
+ result.MessageIncludesErrors = result.Errors.length > 0 && !!message;
2248
2346
  result.StartedAt = new Date();
2249
2347
  result.EndedAt = new Date();
2250
2348
  result.OriginalValues = this.Fields.map(f => ({ FieldName: f.CodeName, Value: f.OldValue }));
@@ -2702,6 +2800,7 @@ export class BaseEntity {
2702
2800
  if (this.EntityInfo?.HasInactiveFields) {
2703
2801
  this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Set');
2704
2802
  }
2803
+ this.AssertFieldReadable(FieldName);
2705
2804
  this.SetLocal(FieldName, Value);
2706
2805
  }
2707
2806
  }
@@ -2721,6 +2820,33 @@ export class BaseEntity {
2721
2820
  EntityFieldInfo.AssertEntityFieldActiveStatus(fi, caller);
2722
2821
  }
2723
2822
  }
2823
+ /**
2824
+ * Field-level security choke point for the strongly-typed accessor path. Throws when the
2825
+ * acting user may not READ the field.
2826
+ *
2827
+ * Called by `Get()` and `Set()` — every generated typed accessor
2828
+ * (`get Salary() { return this.Get('Salary'); }`) routes through them, so these two sites
2829
+ * cover the whole typed surface. Gated on both READ, because a field a user cannot see is
2830
+ * one they cannot meaningfully address by name at all; update and create denials are
2831
+ * enforced on the write path, where a rejection can name a save rather than a keystroke.
2832
+ *
2833
+ * **Deliberately NOT called by `SetMany`.** That is the hydration and resolver-apply path —
2834
+ * throwing there would break loading a record that merely CONTAINS a restricted column.
2835
+ *
2836
+ * **Fails open when no user resolves.** `ActiveUser` is legitimately null in plenty of
2837
+ * server paths, and a gate that threw there would break unrelated code in ways that look
2838
+ * nothing like field security.
2839
+ *
2840
+ * Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
2841
+ * build) reads `EntityField.Value` directly and never routes through here — that exemption
2842
+ * is load-bearing, not an oversight. Do not "fix" it.
2843
+ */
2844
+ AssertFieldReadable(fieldName) {
2845
+ const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
2846
+ if (denied?.has(fieldName?.trim().toLowerCase())) {
2847
+ throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
2848
+ }
2849
+ }
2724
2850
  /**
2725
2851
  * Internal helper that sets a field value directly on THIS entity's own Fields array
2726
2852
  * without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
@@ -2788,6 +2914,10 @@ export class BaseEntity {
2788
2914
  if (this.EntityInfo?.HasInactiveFields) {
2789
2915
  this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Get');
2790
2916
  }
2917
+ // Field security sits BEFORE the raw-mode fast path below, not after: an entity whose
2918
+ // fields are not yet hydrated would otherwise return denied values straight out of _raw
2919
+ // without the gate ever running.
2920
+ this.AssertFieldReadable(FieldName);
2791
2921
  // Raw mode fast path: read directly from the cached data without building EntityField
2792
2922
  // instances. This is the dominant cost in engine warm-loads — generated typed getters
2793
2923
  // (e.g. `get Name() { return this.Get('Name'); }`) flow through here, so a consumer that
@@ -2944,6 +3074,13 @@ export class BaseEntity {
2944
3074
  GetAll(oldValues = false, onlyDirtyFields = false) {
2945
3075
  let obj = {};
2946
3076
  for (let field of this.Fields) {
3077
+ // NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
3078
+ // construction artifact, and serializing it would launder a default/null into
3079
+ // something downstream code treats as data — the exact masquerade the flag exists
3080
+ // to prevent. Key-absence also propagates the flag naturally: hydrating another
3081
+ // entity from this output re-marks the same fields not-loaded.
3082
+ if (field.NotLoaded)
3083
+ continue;
2947
3084
  if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
2948
3085
  // Reads field.Value directly — serialization is framework-internal, so it does not
2949
3086
  // (and must not) assert active status. No suppression toggle needed: the assertion no
@@ -3135,6 +3272,9 @@ export class BaseEntity {
3135
3272
  }
3136
3273
  }
3137
3274
  }
3275
+ // _raw IS a hydration source (LoadFromData fast path) — fields whose key it omitted
3276
+ // are not-loaded, not defaulted. See EntityField.NotLoaded.
3277
+ this.markFieldsOmittedBySourceAsNotLoaded(this._raw);
3138
3278
  // Raw data has been promoted into Fields — release the reference so we don't carry
3139
3279
  // duplicate state. Fields hold their own copies, so a frozen source no longer
3140
3280
  // constrains anything from here on.
@@ -3142,6 +3282,22 @@ export class BaseEntity {
3142
3282
  this._rawConverted = null;
3143
3283
  }
3144
3284
  }
3285
+ /**
3286
+ * Marks every field whose key the given HYDRATION SOURCE omitted as {@link EntityField.NotLoaded}.
3287
+ * Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
3288
+ * {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
3289
+ * where omitting a field means "leave it alone," not "this field was never loaded."
3290
+ * Both the field Name and CodeName are checked, matching SetMany's key acceptance.
3291
+ */
3292
+ markFieldsOmittedBySourceAsNotLoaded(source) {
3293
+ if (!source || typeof source !== 'object')
3294
+ return;
3295
+ for (const field of this.Fields) {
3296
+ if (source[field.Name] === undefined && source[field.CodeName] === undefined) {
3297
+ field.MarkNotLoaded();
3298
+ }
3299
+ }
3300
+ }
3145
3301
  /**
3146
3302
  * This method will copy the values from the other entity object into the current one. This is useful for things like cloning a record.
3147
3303
  * This method will ONLY copy values for fields that exist in the current entity object. If the other object has fields that don't exist in the current object, they will be ignored.
@@ -3155,7 +3311,9 @@ export class BaseEntity {
3155
3311
  for (let field of this.Fields) {
3156
3312
  if (!field.IsPrimaryKey || includePrimaryKeys) {
3157
3313
  const otherField = other.GetFieldByName(field.Name);
3158
- if (otherField) {
3314
+ // Skip fields the SOURCE never loaded (D-3): copying their construction
3315
+ // state would masquerade a default/null as real data on this entity.
3316
+ if (otherField && !otherField.NotLoaded) {
3159
3317
  this.Set(field.Name, otherField.Value);
3160
3318
  if (replaceOldValues) {
3161
3319
  field.ResetOldValue();
@@ -3476,6 +3634,9 @@ export class BaseEntity {
3476
3634
  `Failed to save parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
3477
3635
  // Surface the parent's field-level errors so the caller can act on them.
3478
3636
  newResult.Errors = parentErrors;
3637
+ // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
3638
+ // them — say so, or CompleteMessage repeats every one.
3639
+ newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
3479
3640
  newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
3480
3641
  newResult.EndedAt = new Date();
3481
3642
  this.RegisterResultHistoryEntry(newResult);
@@ -3486,6 +3647,8 @@ export class BaseEntity {
3486
3647
  const type = this.IsSaved ? EntityPermissionType.Update : EntityPermissionType.Create;
3487
3648
  const saveSubType = this.IsSaved ? 'update' : 'create';
3488
3649
  this.CheckPermissions(type, true); // this will throw an error and exit out if we don't have permission
3650
+ this.CheckFieldLevelUpdatePermissions(); // field-level security — throws if a dirty field is not updatable by this user
3651
+ this.ApplyFieldLevelCreateSuppression(); // field-level security on INSERT — omits fields, never rejects
3489
3652
  // IS-A disjoint subtype enforcement: on CREATE, ensure parent record
3490
3653
  // isn't already claimed by another child type (e.g., can't create Meeting
3491
3654
  // if a Publication already exists with the same Product ID).
@@ -3705,6 +3868,14 @@ export class BaseEntity {
3705
3868
  // does not own (e.g. OrderHeader on Event Order Line). Keep only columns
3706
3869
  // this entity defines, and ignore anything leftover.
3707
3870
  this.SetMany(this.ownedFieldsFrom(fieldData), true, true, true);
3871
+ // finalizeSave re-hydrates from the save RESPONSE — a hydration source. Keys it
3872
+ // omitted (e.g. fields the server stripped for field security) must be marked
3873
+ // not-loaded, or the defaults init() just produced would masquerade as confirmed
3874
+ // values and be resent on the NEXT save (the create/update-response corner: a
3875
+ // trigger/system adjustment or concurrent change would be silently stomped).
3876
+ if (typeof fieldData === 'object' && !Array.isArray(fieldData)) {
3877
+ this.markFieldsOmittedBySourceAsNotLoaded(fieldData);
3878
+ }
3708
3879
  this._everSaved = true; // Mark as saved after successful save
3709
3880
  const result = this.LatestResult;
3710
3881
  if (result)
@@ -3824,6 +3995,158 @@ export class BaseEntity {
3824
3995
  else
3825
3996
  return bAllowed;
3826
3997
  }
3998
+ /**
3999
+ * Field-level security on the write path: rejects a save that modifies a field this user
4000
+ * has no update permission on.
4001
+ *
4002
+ * ENFORCEMENT LAYER — read this before treating it as the security boundary. `BaseEntity`
4003
+ * also runs in the browser, where this guard is trivially bypassable. The AUTHORITATIVE
4004
+ * check is the server-side execution of this same code: the MJServer mutation resolver
4005
+ * re-instantiates the entity and re-runs Save on the server, where the client cannot reach
4006
+ * it. The client-side occurrence is UX and defense-in-depth — fail fast with a clear
4007
+ * message before a network round-trip — and must never be relied on alone.
4008
+ *
4009
+ * UPDATE rejects; CREATE does not — see {@link ApplyFieldLevelCreateSuppression}.
4010
+ *
4011
+ * Note this checks DIRTY fields only. CLIENT-side that is safe on its own: nothing ever
4012
+ * nulls a restricted value in memory, so a field the user cannot see was never loaded as
4013
+ * null, is not dirty, and an unrelated edit saves cleanly with the restricted column
4014
+ * keeping its stored value.
4015
+ *
4016
+ * SERVER-side, dirty-only is safe only because `ResolverBase.UpdateRecord` guarantees the
4017
+ * entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
4018
+ * carry that premise, and BOTH are load-bearing:
4019
+ *
4020
+ * 1. `StripDeniedReadFieldsFromClientInput` removes client-sent values for fields the caller
4021
+ * cannot READ, which `SetMany` would otherwise make genuinely dirty with fabricated data.
4022
+ * 2. `entityInfo.EnableFieldLevelSecurity` forces the truth-load branch, so the entity's
4023
+ * non-dirty baseline is the real stored row rather than the client's `OldValues___`.
4024
+ *
4025
+ * (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
4026
+ * EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
4027
+ * never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
4028
+ * `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
4029
+ * canonical FLS configuration, and one that leaves (1) with nothing to strip — could write an
4030
+ * update-denied field just by pinning its value in `OldValues___` and never naming it in the
4031
+ * mutation. If you are considering relaxing that branch condition, this check is what breaks.
4032
+ *
4033
+ * The refusal names the missing permission when the caller can READ the field, and falls back
4034
+ * to the ambiguous "does not exist or you do not have access" wording when they cannot. See
4035
+ * {@link FieldSecurityWriteDenialMessage} for why that split discloses nothing.
4036
+ */
4037
+ CheckFieldLevelUpdatePermissions() {
4038
+ if (!this.IsSaved) {
4039
+ return; // INSERT — handled by ApplyFieldLevelCreateSuppression, which never rejects
4040
+ }
4041
+ const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedUpdateFields(u));
4042
+ if (!denied) {
4043
+ return;
4044
+ }
4045
+ // Resolved once, and only if we are actually going to reject: the wording depends on
4046
+ // whether the caller can READ the field they were refused a write on.
4047
+ let deniedRead;
4048
+ for (const field of this.Fields) {
4049
+ const key = field.Name.trim().toLowerCase();
4050
+ if (field.Dirty && denied.has(key)) {
4051
+ LogDebug(`[FieldSecurity] Rejected save on '${this.EntityInfo.Name}': ` +
4052
+ `field '${field.Name}' is not updatable by this user`);
4053
+ // A field the caller can READ gets the real reason. Both facts the ambiguous
4054
+ // wording protects — that the column exists, and that it is restricted for them —
4055
+ // are already theirs, so withholding the reason only tells someone a field whose
4056
+ // values they are looking at might not exist.
4057
+ //
4058
+ // A field they CANNOT read keeps the ambiguous wording. Not hypothetical: SetMany
4059
+ // deliberately skips the readability assertion (hydration / resolver-apply path),
4060
+ // so server-side code can dirty a read-denied field and land here.
4061
+ if (deniedRead === undefined) {
4062
+ deniedRead = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
4063
+ }
4064
+ throw deniedRead?.has(key)
4065
+ ? new FieldSecurityError(field.Name, this.EntityInfo.Name)
4066
+ : FieldSecurityError.WriteDenial(field.Name, this.EntityInfo.Name);
4067
+ }
4068
+ }
4069
+ }
4070
+ /**
4071
+ * Field-level security on the INSERT path: marks the fields this user may not supply so the
4072
+ * save omits them and each column takes its database default.
4073
+ *
4074
+ * **This never rejects, and that is deliberate.** Rejecting would be inconsistent with the
4075
+ * read path (a denied field is simply absent, not an error) and would leak information — an
4076
+ * error naming `Salary` confirms the field exists and is restricted, which the ambiguous
4077
+ * denial wording exists to prevent. Silently defaulting is also what an unrestricted user
4078
+ * gets by leaving the field blank, so a restricted user creating a record ends up with the
4079
+ * same record SHAPE rather than a failure.
4080
+ *
4081
+ * The cost is that a user who supplies a value for a create-denied field gets no feedback
4082
+ * that it was dropped, which is why the drop is logged and why the admin UI should not
4083
+ * render the field at all.
4084
+ *
4085
+ * Runs on every save (clearing prior marks first) because the answer depends on the acting
4086
+ * user, and one entity object can be saved by different users over its lifetime.
4087
+ */
4088
+ ApplyFieldLevelCreateSuppression() {
4089
+ for (const field of this.Fields) {
4090
+ field.SetCreateSuppressed(false);
4091
+ }
4092
+ if (this.IsSaved) {
4093
+ return; // UPDATE — CheckFieldLevelUpdatePermissions owns that path
4094
+ }
4095
+ const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedCreateFields(u));
4096
+ if (!denied) {
4097
+ return;
4098
+ }
4099
+ const suppressed = [];
4100
+ for (const field of this.Fields) {
4101
+ if (denied.has(field.Name.trim().toLowerCase())) {
4102
+ field.SetCreateSuppressed(true);
4103
+ suppressed.push(field.Name);
4104
+ }
4105
+ }
4106
+ if (suppressed.length > 0) {
4107
+ LogDebug(`[FieldSecurity] Create on '${this.EntityInfo.Name}': ` +
4108
+ `omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
4109
+ }
4110
+ }
4111
+ /**
4112
+ * The denied-field set for the acting user, or null when field security does not apply —
4113
+ * the entity has it switched off, no user resolves, or the user is denied nothing.
4114
+ *
4115
+ * Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
4116
+ * the three cheap short-circuits in one place instead of repeated at each gate.
4117
+ */
4118
+ deniedFieldsForActiveUser(select) {
4119
+ if (!this.EntityInfo?.EnableFieldLevelSecurity) {
4120
+ return null; // one boolean for the overwhelming majority of entities
4121
+ }
4122
+ const u = this.resolveActiveUserOrNull();
4123
+ if (!u) {
4124
+ return null; // no user resolves — fail open, see AssertFieldReadable
4125
+ }
4126
+ const denied = select(u);
4127
+ return denied.size > 0 ? denied : null;
4128
+ }
4129
+ /**
4130
+ * {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
4131
+ * one from.
4132
+ *
4133
+ * `ActiveUser` ends in `Metadata.Provider.CurrentUser`, which throws a TypeError when there
4134
+ * is no global provider — during early boot, in tests, or in any context that never
4135
+ * configured one. That was harmless while only the save path consulted it, but `Get()` and
4136
+ * `Set()` now do on every access to an FLS-enabled entity, so an unresolvable provider would
4137
+ * turn an ordinary read into a crash.
4138
+ *
4139
+ * "No provider to ask" is the same answer as "no user" for this purpose, and field security
4140
+ * fails open on both.
4141
+ */
4142
+ resolveActiveUserOrNull() {
4143
+ try {
4144
+ return this.ActiveUser ?? null;
4145
+ }
4146
+ catch {
4147
+ return null;
4148
+ }
4149
+ }
3827
4150
  ThrowPermissionError(u, type, additionalInfoMessage) {
3828
4151
  throw new Error(`User: ${u.Name} (ID: ${u.ID}, Email: ${u.Email})
3829
4152
  Does NOT have permission to ${EntityPermissionType[type]} ${this.EntityInfo.Name} records.
@@ -3891,6 +4214,10 @@ export class BaseEntity {
3891
4214
  f.ResetNeverSetFlag();
3892
4215
  }
3893
4216
  this.SetMany(data, false, true, true); // don't ignore non-existent fields, but DO replace old values
4217
+ // InnerLoad is a hydration entry point: any field the provider's row omitted (e.g.
4218
+ // a client-side load whose server response stripped read-denied fields) is
4219
+ // not-loaded, so its constructor state never masquerades as data on the next save.
4220
+ this.markFieldsOmittedBySourceAsNotLoaded(data);
3894
4221
  if (EntityRelationshipsToLoad) {
3895
4222
  for (let relationship of EntityRelationshipsToLoad) {
3896
4223
  if (data[relationship]) {
@@ -4052,6 +4379,10 @@ export class BaseEntity {
4052
4379
  // Hits when: subsequent LoadFromData call on an already-loaded instance, IS-A entity
4053
4380
  // (parent or child), or non-plain-object input. Preserves original semantics exactly.
4054
4381
  this.SetMany(data, true, _replaceOldValues, true); // ignore non-existent fields, but DO replace old values based on the provided param
4382
+ if (isPlainObject) {
4383
+ // LoadFromData is a hydration entry point: keys the source omitted are not-loaded.
4384
+ this.markFieldsOmittedBySourceAsNotLoaded(data);
4385
+ }
4055
4386
  // now, check to see if we have the primary key set, if so, we should consider ourselves
4056
4387
  // loaded from the database and set the _recordLoaded flag to true along with the _everSaved flag
4057
4388
  if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
@@ -4361,6 +4692,9 @@ export class BaseEntity {
4361
4692
  `Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
4362
4693
  // Surface the parent's field-level errors so the caller can act on them.
4363
4694
  newResult.Errors = parentErrors;
4695
+ // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
4696
+ // them — say so, or CompleteMessage repeats every one.
4697
+ newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
4364
4698
  newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
4365
4699
  newResult.EndedAt = new Date();
4366
4700
  this.RegisterResultHistoryEntry(newResult);
@@ -4653,9 +4987,20 @@ export class BaseEntity {
4653
4987
  if (!f) {
4654
4988
  return null;
4655
4989
  }
4656
- else {
4657
- return this.Get(f.Name);
4990
+ // Field security: the name field is an ordinary field and can be denied like any other.
4991
+ // `Get()` THROWS for a denied field, and this method runs AUTOMATICALLY after every
4992
+ // Load / LoadFromData / Save via CacheRecordName — so an unguarded read here does not
4993
+ // hide a name, it makes the record fail to load at all, with a message about the name
4994
+ // field that reads like the record itself is broken.
4995
+ //
4996
+ // Returning null is the same answer callers already handle for "this entity has no name
4997
+ // field", and every one of them degrades to the primary key. It also keeps a denied name
4998
+ // OUT of the provider's record-name cache, which is keyed by entity + primary key and NOT
4999
+ // by user — caching it would leak it to the next caller.
5000
+ if (!this.EntityInfo.IsFieldReadableByUser(f.Name, this.ActiveUser)) {
5001
+ return null;
4658
5002
  }
5003
+ return this.Get(f.Name);
4659
5004
  }
4660
5005
  /**
4661
5006
  * Static Utility method to get RecordChanges for a given entityName/KeyValuePair combination