@memberjunction/core 6.1.0-edge.5 → 6.1.0-edge.7

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 (98) hide show
  1. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
  2. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
  3. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
  4. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
  5. package/dist/generic/baseEngine.d.ts +52 -1
  6. package/dist/generic/baseEngine.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.js +95 -7
  8. package/dist/generic/baseEngine.js.map +1 -1
  9. package/dist/generic/baseEntity.d.ts +292 -0
  10. package/dist/generic/baseEntity.d.ts.map +1 -1
  11. package/dist/generic/baseEntity.js +878 -98
  12. package/dist/generic/baseEntity.js.map +1 -1
  13. package/dist/generic/baseInfo.d.ts +6 -0
  14. package/dist/generic/baseInfo.d.ts.map +1 -1
  15. package/dist/generic/baseInfo.js +19 -1
  16. package/dist/generic/baseInfo.js.map +1 -1
  17. package/dist/generic/compositeKey.d.ts +108 -0
  18. package/dist/generic/compositeKey.d.ts.map +1 -1
  19. package/dist/generic/compositeKey.js +225 -10
  20. package/dist/generic/compositeKey.js.map +1 -1
  21. package/dist/generic/databaseProviderBase.d.ts +49 -0
  22. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  23. package/dist/generic/databaseProviderBase.js +90 -11
  24. package/dist/generic/databaseProviderBase.js.map +1 -1
  25. package/dist/generic/embeddedRecord.d.ts +2 -1
  26. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  27. package/dist/generic/embeddedRecord.js +9 -2
  28. package/dist/generic/embeddedRecord.js.map +1 -1
  29. package/dist/generic/entityCompanion.d.ts +6 -1
  30. package/dist/generic/entityCompanion.d.ts.map +1 -1
  31. package/dist/generic/entityCompanion.js +7 -0
  32. package/dist/generic/entityCompanion.js.map +1 -1
  33. package/dist/generic/entityInfo.d.ts +550 -8
  34. package/dist/generic/entityInfo.d.ts.map +1 -1
  35. package/dist/generic/entityInfo.js +695 -20
  36. package/dist/generic/entityInfo.js.map +1 -1
  37. package/dist/generic/entitySubtypeResolver.d.ts +19 -0
  38. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
  39. package/dist/generic/entitySubtypeResolver.js +24 -0
  40. package/dist/generic/entitySubtypeResolver.js.map +1 -0
  41. package/dist/generic/entityTransactionScope.d.ts +6 -3
  42. package/dist/generic/entityTransactionScope.d.ts.map +1 -1
  43. package/dist/generic/entityTransactionScope.js +6 -3
  44. package/dist/generic/entityTransactionScope.js.map +1 -1
  45. package/dist/generic/extendedTypeValue.d.ts +47 -0
  46. package/dist/generic/extendedTypeValue.d.ts.map +1 -0
  47. package/dist/generic/extendedTypeValue.js +152 -0
  48. package/dist/generic/extendedTypeValue.js.map +1 -0
  49. package/dist/generic/filters/compositeFilter.d.ts +77 -0
  50. package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
  51. package/dist/generic/filters/compositeFilter.js +324 -0
  52. package/dist/generic/filters/compositeFilter.js.map +1 -0
  53. package/dist/generic/filters/filter.types.d.ts +29 -0
  54. package/dist/generic/filters/filter.types.d.ts.map +1 -0
  55. package/dist/generic/filters/filter.types.js +28 -0
  56. package/dist/generic/filters/filter.types.js.map +1 -0
  57. package/dist/generic/filters/index.d.ts +3 -0
  58. package/dist/generic/filters/index.d.ts.map +1 -0
  59. package/dist/generic/filters/index.js +3 -0
  60. package/dist/generic/filters/index.js.map +1 -0
  61. package/dist/generic/geoFields.d.ts +64 -0
  62. package/dist/generic/geoFields.d.ts.map +1 -0
  63. package/dist/generic/geoFields.js +108 -0
  64. package/dist/generic/geoFields.js.map +1 -0
  65. package/dist/generic/interfaces.d.ts +16 -2
  66. package/dist/generic/interfaces.d.ts.map +1 -1
  67. package/dist/generic/interfaces.js.map +1 -1
  68. package/dist/generic/localCacheManager.d.ts +34 -3
  69. package/dist/generic/localCacheManager.d.ts.map +1 -1
  70. package/dist/generic/localCacheManager.js +53 -4
  71. package/dist/generic/localCacheManager.js.map +1 -1
  72. package/dist/generic/providerBase.d.ts +282 -5
  73. package/dist/generic/providerBase.d.ts.map +1 -1
  74. package/dist/generic/providerBase.js +754 -93
  75. package/dist/generic/providerBase.js.map +1 -1
  76. package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
  77. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
  78. package/dist/generic/recordChangeFieldSecurity.js +279 -0
  79. package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
  80. package/dist/generic/relatedRecordBatchLoader.js +2 -2
  81. package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
  82. package/dist/generic/relatedRecordCollection.d.ts +2 -1
  83. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  84. package/dist/generic/relatedRecordCollection.js +16 -4
  85. package/dist/generic/relatedRecordCollection.js.map +1 -1
  86. package/dist/generic/saveEntityGraphOperation.d.ts +10 -0
  87. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  88. package/dist/generic/saveEntityGraphOperation.js +2 -1
  89. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  90. package/dist/generic/wellKnownUserSource.d.ts +70 -0
  91. package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
  92. package/dist/generic/wellKnownUserSource.js +82 -0
  93. package/dist/generic/wellKnownUserSource.js.map +1 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +7 -0
  97. package/dist/index.js.map +1 -1
  98. package/package.json +3 -3
@@ -3,8 +3,26 @@ import { Metadata } from "./metadata.js";
3
3
  import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, FormatValue, CodeNameFromString } from "./util.js";
4
4
  import { IsFixedWidthStringSQLType } from "@memberjunction/sql-dialect";
5
5
  import { LogError } from "./logging.js";
6
- import { WarningManager, SafeJSONParse, UUIDsEqual } from "@memberjunction/global";
6
+ import { WarningManager, SafeJSONParse, UUIDsEqual, ordinalCompare } from "@memberjunction/global";
7
7
  import { ReadRelationshipJoinFields, } from "./entityConfiguration.js";
8
+ /**
9
+ * Runtime domain for {@link EntityFieldInfo.ExtendedType}. This array is the single source of
10
+ * truth; {@link EntityFieldExtendedType} is derived from it. CodeGen validates LLM suggestions
11
+ * against {@link EntityFieldInfo.ExtendedTypes} rather than duplicating the list.
12
+ *
13
+ * `Image` — the value is an image URL, a `data:image/...` URI, or raw image base64. UI surfaces
14
+ * render a thumbnail and (in edit mode) allow replacing it with an upload capped at the field's
15
+ * MaxLength.
16
+ * `Color` — the value is a CSS color (hex / rgb / hsl).
17
+ * `JSON` — the value is a JSON document; validated on save and pretty-printed in forms.
18
+ */
19
+ export const EntityFieldExtendedTypes = [
20
+ 'Code', 'Color', 'Email', 'FaceTime', 'Geo',
21
+ 'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
22
+ 'GeoCity', 'GeoPostalCode', 'GeoAddress',
23
+ 'HTML', 'Icon', 'Image', 'JSON', 'Markdown',
24
+ 'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg',
25
+ ];
8
26
  /**
9
27
  * The possible status values for a record change
10
28
  */
@@ -209,7 +227,7 @@ export class EntityOrganicKeyInfo extends BaseInfo {
209
227
  sorted.sort((a, b) => {
210
228
  const aSeq = a.Sequence ?? 999999;
211
229
  const bSeq = b.Sequence ?? 999999;
212
- return aSeq - bSeq;
230
+ return (aSeq - bSeq) || ordinalCompare(a.RelatedEntity, b.RelatedEntity) || ordinalCompare(a.ID, b.ID);
213
231
  });
214
232
  for (const item of sorted) {
215
233
  this._RelatedEntities.push(new EntityOrganicKeyRelatedEntityInfo(item));
@@ -305,6 +323,15 @@ export class EntityUserPermissionInfo {
305
323
  * Controls which roles can perform create, read, update, and delete operations.
306
324
  */
307
325
  export class EntityPermissionInfo extends BaseInfo {
326
+ /**
327
+ * True when this row is a Deny row (`Type = 'Deny'`, compared case- and whitespace-insensitively;
328
+ * a null/blank Type — rows created before the column existed — is Allow). On a Deny row a set
329
+ * `Can*` flag means "deny that operation", so nothing that reads a `Can*` flag as a GRANT may
330
+ * look at a Deny row: `GetUserPermisions` subtracts these, and the RLS readers skip them.
331
+ */
332
+ get IsDeny() {
333
+ return (this.Type || 'Allow').trim().toLowerCase() === 'deny';
334
+ }
308
335
  get CreateRLSFilterObject() {
309
336
  return this.RLSFilter(EntityPermissionType.Create);
310
337
  }
@@ -369,6 +396,190 @@ export class EntityPermissionInfo extends BaseInfo {
369
396
  this.copyInitData(initData);
370
397
  }
371
398
  }
399
+ /**
400
+ * The three states a single field-level permission verb can hold, modelled on SQL Server's
401
+ * posture:
402
+ *
403
+ * - `No Access` — neutral, and the default. Grants nothing and blocks nothing; another role's
404
+ * Allow still wins.
405
+ * - `Allow` — grants the action for this role.
406
+ * - `Deny` — trumps everything. One Deny across any of the user's roles wins no matter how
407
+ * many Allows sit beside it.
408
+ */
409
+ export const FieldPermissionAccess = {
410
+ Allow: 'Allow',
411
+ Deny: 'Deny',
412
+ NoAccess: 'No Access',
413
+ };
414
+ /**
415
+ * The transport-only key carrying the server's authoritative answer to "which fields on this
416
+ * entity may the caller of THIS request read".
417
+ *
418
+ * **Why it lists READABLE fields rather than denied ones.** The two carry the same information
419
+ * only while the client already holds the full permission matrix, which it does today — the
420
+ * `MJ_Metadata` dataset ships `MJ: Entity Fields` and `MJ: Entity Field Permissions` unfiltered.
421
+ * That is scheduled to change (MJ issue #3485, metadata filtering for restricted users), and a
422
+ * payload that named DENIED fields would hand back exactly what such filtering exists to withhold:
423
+ * the names of columns you are not allowed to know about. A readable list names only fields the
424
+ * caller may already see, so it discloses nothing under any filtering design.
425
+ *
426
+ * **Why it is needed at all.** The server omits denied fields from the response object, but
427
+ * GraphQL emits every SELECTED field regardless — so a denied field the client asked for arrives
428
+ * as an explicit `null`, indistinguishable from a genuine one. The client cannot settle that from
429
+ * its own metadata: in the window after a permission change (and permanently, once metadata is
430
+ * filtered) the client's copy disagrees with the server's. This key is the server stating it
431
+ * in-band, for the request that actually ran.
432
+ *
433
+ * Suffixed `___` following the established transport-only convention (`OldValues___`,
434
+ * `RestoreContext___`) so it cannot collide with a real column name.
435
+ */
436
+ export const ReadableFieldsTransportKey = 'ReadableFields___';
437
+ /**
438
+ * True when a rule takes access AWAY rather than granting or abstaining.
439
+ *
440
+ * `Deny` is the only restricting value *within a single rule*. `No Access` is the aggregation's
441
+ * identity element: it can leave a role without access, but it can never remove access one of the
442
+ * user's other roles granted.
443
+ *
444
+ * **This answers a question about one rule, not about a change.** Whether a CHANGE restricts a
445
+ * user is a property of the aggregate across every role they hold — setting each of a user's roles
446
+ * to `No Access` in turn writes no `Deny` anywhere and still ends with the field denied. So this
447
+ * is sound for an INSERT, which can only add rules and therefore cannot remove an existing
448
+ * `Allow`, and is NOT sufficient for an edit or a delete. Those must project the resulting rule
449
+ * set and aggregate it — see {@link EntityFieldInfo.AggregateFieldRulesForUser}.
450
+ */
451
+ export function IsRestrictingFieldRule(rule) {
452
+ return (rule?.ReadAccess === FieldPermissionAccess.Deny ||
453
+ rule?.UpdateAccess === FieldPermissionAccess.Deny ||
454
+ rule?.CreateAccess === FieldPermissionAccess.Deny);
455
+ }
456
+ /**
457
+ * The single wording for "you cannot use this field", modelled on SQL Server's posture of never
458
+ * disclosing whether an object is missing or merely inaccessible.
459
+ *
460
+ * Naming the field is safe — the caller supplied it, so it tells them nothing they did not
461
+ * already know. Naming the REASON is not: confirming "this field exists and is restricted"
462
+ * turns any predicate into an oracle for probing which columns a deployment considers
463
+ * sensitive. The ambiguity also stays correct after
464
+ * [#3485](https://github.com/MemberJunction/MJ/issues/3485) tiers metadata and restricted fields
465
+ * stop shipping to clients at all, at which point "does not exist" becomes literally true from
466
+ * the client's vantage point.
467
+ *
468
+ * Lives here rather than on `ProviderBase` so the write path in `BaseEntity` can reach it
469
+ * without importing the provider layer, which imports `BaseEntity` in turn.
470
+ */
471
+ export function FieldSecurityDenialMessage(fieldName, entityName) {
472
+ return `Field '${fieldName}' does not exist on entity '${entityName}' or you do not have access to it.`;
473
+ }
474
+ /**
475
+ * The wording for "you may not WRITE this field" — used only when the caller can READ it.
476
+ *
477
+ * Naming the reason here discloses nothing. The caller can see the field and its values, so
478
+ * both facts the ambiguous wording withholds — that the column exists, and that it is
479
+ * restricted for them — are already theirs. All this adds is *which* permission is missing,
480
+ * which they would learn by trying anyway.
481
+ *
482
+ * The two justifications behind {@link FieldSecurityDenialMessage} do not reach this case:
483
+ * predicate probing is a question about columns the caller cannot READ, and the
484
+ * [#3485](https://github.com/MemberJunction/MJ/issues/3485) argument — that "does not exist"
485
+ * becomes literally true once restricted fields stop shipping to clients — is false for a
486
+ * readable field, which keeps shipping. Ambiguity there does not age into truth; it just tells
487
+ * someone that a field they are looking at might not exist.
488
+ *
489
+ * A field the caller cannot read must still use the ambiguous wording. That is not hypothetical:
490
+ * `SetMany` deliberately skips the readability assertion (it is the hydration and resolver-apply
491
+ * path), so server-side code can dirty a read-denied field and reach the update gate.
492
+ */
493
+ export function FieldSecurityWriteDenialMessage(fieldName, entityName) {
494
+ return `You do not have permission to update field '${fieldName}' on entity '${entityName}'.`;
495
+ }
496
+ /**
497
+ * The error thrown when field-level security refuses a request — a caller-authored predicate
498
+ * naming an unreadable field, a typed accessor touching one, or a save modifying a field the
499
+ * caller may not write.
500
+ *
501
+ * A DISTINCT class because its message is the one security rejection that is deliberately safe
502
+ * to show a caller: both {@link FieldSecurityDenialMessage} and
503
+ * {@link FieldSecurityWriteDenialMessage} were designed for exactly that surface and disclose
504
+ * nothing (see their docs). Transport layers that rightly swallow arbitrary resolver errors
505
+ * (whose messages can carry SQL text or internal state) recognize this one and let it through,
506
+ * so the intended wording reaches the wire instead of degenerating into a generic transport
507
+ * error.
508
+ *
509
+ * Recognize it by `name === FieldSecurityError.ErrorName` rather than `instanceof` where
510
+ * bundling might duplicate the class.
511
+ */
512
+ export class FieldSecurityError extends Error {
513
+ static { this.ErrorName = 'FieldSecurityError'; }
514
+ /**
515
+ * Defaults to the ambiguous wording, which is correct for every READ denial. Pass `message`
516
+ * only through a named factory such as {@link FieldSecurityError.WriteDenial}, so the choice
517
+ * of wording is always a deliberate, reviewable decision rather than an inline string.
518
+ */
519
+ constructor(fieldName, entityName, message) {
520
+ super(message ?? FieldSecurityDenialMessage(fieldName, entityName));
521
+ this.name = FieldSecurityError.ErrorName;
522
+ }
523
+ /**
524
+ * A write refusal on a field the caller CAN read — names the missing permission instead of
525
+ * hiding behind "or it does not exist", which would be actively misleading about a field
526
+ * whose values they are looking at. Callers must confirm readability first; see
527
+ * {@link FieldSecurityWriteDenialMessage}.
528
+ */
529
+ static WriteDenial(fieldName, entityName) {
530
+ return new FieldSecurityError(fieldName, entityName, FieldSecurityWriteDenialMessage(fieldName, entityName));
531
+ }
532
+ }
533
+ /**
534
+ * Field-level (column-level) security settings. Maps an entity FIELD to a role, carrying three
535
+ * independent trinary verbs — Read, Update and Create. One row per (field, role).
536
+ *
537
+ * These rows are only consulted when the parent entity has
538
+ * {@link EntityInfo.EnableFieldLevelSecurity} set. Aggregation across the roles a user holds,
539
+ * per verb: `effective = (any matching row Allows) AND NOT (any matching row Denies)`. See
540
+ * {@link EntityFieldInfo.GetUserFieldPermissions}.
541
+ */
542
+ export class EntityFieldPermissionInfo extends BaseInfo {
543
+ /**
544
+ * @param initData raw metadata row off the wire. `BaseInfo.copyInitData` only ever reads
545
+ * `Object.keys()` off it, so a plain record is wide enough.
546
+ */
547
+ constructor(initData = null) {
548
+ super();
549
+ this.ID = null;
550
+ this.EntityFieldID = null;
551
+ this.RoleID = null;
552
+ /**
553
+ * Whether this role may READ the field's values. The aggregation normalizes defensively and
554
+ * treats anything unrecognized as `No Access`, so a bad value off the wire fails closed
555
+ * rather than granting.
556
+ */
557
+ this.ReadAccess = FieldPermissionAccess.NoAccess;
558
+ /**
559
+ * Whether this role may modify the field's value on an EXISTING record.
560
+ *
561
+ * Requires {@link ReadAccess} = Allow — a field a user cannot see is one they cannot
562
+ * change. Enforced per row by a CHECK constraint, and again after aggregation because the
563
+ * constraint cannot see across roles.
564
+ */
565
+ this.UpdateAccess = FieldPermissionAccess.NoAccess;
566
+ /**
567
+ * Whether this role may supply the field's value when INSERTing a record. Requires
568
+ * {@link ReadAccess} = Allow, on the same two-layer basis as {@link UpdateAccess}.
569
+ *
570
+ * A user who may not create a field does not get an error — the supplied value is dropped
571
+ * and the column takes its default, matching the read path where a denied field is simply
572
+ * absent.
573
+ */
574
+ this.CreateAccess = FieldPermissionAccess.NoAccess;
575
+ this.__mj_CreatedAt = null;
576
+ this.__mj_UpdatedAt = null;
577
+ // virtual fields - returned by the database VIEW
578
+ this.EntityField = null;
579
+ this.Role = null;
580
+ this.copyInitData(initData);
581
+ }
582
+ }
372
583
  export const EntityFieldTSType = {
373
584
  String: 'string',
374
585
  Number: 'number',
@@ -428,6 +639,10 @@ export const GeneratedFormSectionType = {
428
639
  * Includes data types, relationships, defaults, and UI display preferences.
429
640
  */
430
641
  export class EntityFieldInfo extends BaseInfo {
642
+ /**
643
+ * Runtime domain for {@link ExtendedType}. Same array as {@link EntityFieldExtendedTypes}.
644
+ */
645
+ static { this.ExtendedTypes = EntityFieldExtendedTypes; }
431
646
  /**
432
647
  * Optional JSON configuration bag (shape = {@link IEntityFieldConfiguration}).
433
648
  * Defines field-level configurations such as Hierarchy options (IsHierarchy, MaxDepth).
@@ -508,6 +723,201 @@ export class EntityFieldInfo extends BaseInfo {
508
723
  }
509
724
  return this._EntityFieldValues;
510
725
  }
726
+ /**
727
+ * Field-level (column-level) security records configured for THIS field, across all roles.
728
+ * Empty for the overwhelming majority of fields — see {@link HasFieldPermissions}.
729
+ */
730
+ get FieldPermissions() {
731
+ return this._FieldPermissions;
732
+ }
733
+ /**
734
+ * True when at least one {@link EntityFieldPermissionInfo} record exists for this field.
735
+ *
736
+ * **Not an enforcement gate** — it answers "does any configuration target this field",
737
+ * which CodeGen's DB-tier emission and the system-user entanglement guard both need. The
738
+ * access decision is {@link EntityInfo.EnableFieldLevelSecurity} plus the aggregation; on an
739
+ * enabled entity a field with no records is denied, not open.
740
+ */
741
+ get HasFieldPermissions() {
742
+ return this._FieldPermissions.length > 0;
743
+ }
744
+ /**
745
+ * Entities whose fields can never be restricted by field-level security.
746
+ *
747
+ * Two distinct reasons, both amounting to "a configuration that cannot be undone through
748
+ * the product":
749
+ *
750
+ * 1. **The security-configuration surface** (Entities, Entity Fields, Entity Permissions,
751
+ * Entity Field Permissions, Roles). Restricting `CanRead` on the Entity Field Permissions
752
+ * entity itself would leave the admin screen unable to render the very rows needed to
753
+ * reverse the restriction — recovery would require direct SQL against the database.
754
+ * 2. **The identity surface** (Users, User Roles). Role resolution and the auth path read
755
+ * these on every request; restricting a column here degrades far more than one screen.
756
+ *
757
+ * Note this is deliberately a guard on WHICH ENTITIES are restrictable, not an exemption for
758
+ * particular USERS. No user is above a Deny — that would undercut the entire point of the
759
+ * feature for the confidentiality use cases (compensation, donor giving) that motivate it.
760
+ *
761
+ * Stored lowercased; compare with a trimmed, lowercased entity name.
762
+ */
763
+ static { this.UnrestrictableEntityNames = new Set([
764
+ 'mj: entities',
765
+ 'mj: entity fields',
766
+ 'mj: entity permissions',
767
+ 'mj: entity field permissions',
768
+ 'mj: roles',
769
+ 'mj: users',
770
+ 'mj: user roles',
771
+ ]); }
772
+ /**
773
+ * True when this field belongs to an entity that field-level security may never restrict.
774
+ * See {@link EntityFieldInfo.UnrestrictableEntityNames} for the rationale.
775
+ */
776
+ get IsOnUnrestrictableEntity() {
777
+ return EntityFieldInfo.UnrestrictableEntityNames.has((this.Entity ?? '').trim().toLowerCase());
778
+ }
779
+ /**
780
+ * True for fields that must remain readable regardless of any permission record:
781
+ * primary keys (hard or soft) and `__mj_` system columns.
782
+ *
783
+ * Stripping a primary key from a result breaks entity load, {@link CompositeKey}
784
+ * construction, relationship resolution, and cache fingerprinting — the failure surfaces
785
+ * far from the permission record that caused it. This is enforced here AND at save time on
786
+ * the permission record itself, so a row inserted outside the entity path still cannot take
787
+ * a primary key out of a result set.
788
+ */
789
+ get IsUnrestrictableField() {
790
+ return this.IsPrimaryKey === true || this.IsSoftPrimaryKey === true || (this.Name ?? '').startsWith('__mj_');
791
+ }
792
+ /**
793
+ * Returns the effective field-level access this user has to this field, aggregating the
794
+ * field's permission records across every role the user holds.
795
+ *
796
+ * **PRECONDITION: the caller has already established that the parent entity has
797
+ * {@link EntityInfo.EnableFieldLevelSecurity} set.** The flag is a required parameter rather
798
+ * than something this method looks up, because `EntityFieldInfo` holds its entity's NAME and
799
+ * not a reference to the `EntityInfo` — and a method that silently answered "denied" for a
800
+ * field on a non-FLS entity would be a trap. Pass `false` and every field comes back fully
801
+ * open.
802
+ *
803
+ * Per verb, across the user's matching roles:
804
+ * `effective = (any row Allows) AND NOT (any row Denies)`. Deny is absorbing and No Access
805
+ * is the identity, so three states collapse to that one expression.
806
+ *
807
+ * Outcomes:
808
+ * - **Field security disabled on the entity** → fully open.
809
+ * - **No records on the field** (enabled) → fully closed. Snapshot initialization creates a
810
+ * row for every (field, role) that should have one, so a missing row means reconciliation
811
+ * has not run — failing closed makes that visible.
812
+ * - **Records exist, none match the user's roles** → fully closed, for want of an Allow.
813
+ *
814
+ * **There is no exempt user — not even the MJ system user.** Every account, including the
815
+ * one the server runs its own background work as, gets its access from the rows. The system
816
+ * user stays working because it holds the standard roles (UI, Developer, Integration),
817
+ * snapshot initialization writes them `Allow` rows like any other role holding entity read,
818
+ * and the save-time configuration guards refuse a `Deny` aimed at a role it holds. That is a
819
+ * constraint on what can be CONFIGURED, which an administrator can see and reason about —
820
+ * unlike a runtime bypass, which is invisible at the point where access is decided and has
821
+ * to be trusted rather than checked.
822
+ *
823
+ * PERFORMANCE: this is the per-FIELD primitive. Enforcement points must never call it
824
+ * inside a per-row loop — `MapFieldNamesToCodeNames` runs once per row, so a naive call
825
+ * site costs `fields x rows` aggregations. Compute the denied-field Set once per
826
+ * (entity, user) per request and pass it into the row loop.
827
+ *
828
+ * @param user the user whose effective access is being resolved
829
+ * @param entityFieldSecurityEnabled the parent entity's `EnableFieldLevelSecurity` flag
830
+ */
831
+ GetUserFieldPermissions(user, entityFieldSecurityEnabled) {
832
+ if (!entityFieldSecurityEnabled) {
833
+ return EntityFieldInfo.fullyOpenFieldPermissions();
834
+ }
835
+ // Records on an unrestrictable entity are ignored entirely rather than half-applied.
836
+ // Save-time validation rejects such rows, so reaching here means they were written
837
+ // outside the entity path.
838
+ if (this.IsOnUnrestrictableEntity) {
839
+ return EntityFieldInfo.fullyOpenFieldPermissions();
840
+ }
841
+ // Primary keys and __mj_ system columns are forced open BEFORE aggregation rather than
842
+ // patched afterwards: a half-corrected result (readable but not creatable) would break
843
+ // inserts on entities whose primary key the caller supplies.
844
+ if (this.IsUnrestrictableField) {
845
+ return EntityFieldInfo.fullyOpenFieldPermissions();
846
+ }
847
+ return this.aggregateUserFieldPermissions(user);
848
+ }
849
+ /**
850
+ * The "field security does not apply here" answer, named so the policy exits above cannot
851
+ * drift apart from one another.
852
+ */
853
+ static fullyOpenFieldPermissions() {
854
+ return { CanRead: true, CanUpdate: true, CanCreate: true };
855
+ }
856
+ /**
857
+ * Trinary aggregation across the user's roles. Split out of
858
+ * {@link GetUserFieldPermissions} so the guards there read as policy and this reads as
859
+ * arithmetic.
860
+ */
861
+ aggregateUserFieldPermissions(user) {
862
+ return EntityFieldInfo.AggregateFieldRulesForUser(this._FieldPermissions, user);
863
+ }
864
+ /**
865
+ * The same aggregation {@link GetUserFieldPermissions} performs, over a rule list the caller
866
+ * supplies rather than this field's stored one.
867
+ *
868
+ * Exists so save-time guards can evaluate a **prospective** outcome — the rules as they would
869
+ * stand after a proposed insert, edit or delete — instead of classifying a single row in
870
+ * isolation. That distinction is load-bearing: whether a change restricts a user is a property
871
+ * of the AGGREGATE across all the roles they hold, not of any one rule. A rule reading
872
+ * `No Access` restricts nobody on its own, yet setting every one of a user's roles to
873
+ * `No Access` leaves no `Allow` standing and denies the field outright.
874
+ *
875
+ * @param rules the rules to aggregate — any shape carrying a `RoleID` and the three verbs
876
+ * @param user the user whose roles select which rules apply
877
+ */
878
+ static AggregateFieldRulesForUser(rules, user) {
879
+ const allow = { CanRead: false, CanUpdate: false, CanCreate: false };
880
+ const deny = { CanRead: false, CanUpdate: false, CanCreate: false };
881
+ for (const fp of rules) {
882
+ const roleMatch = user?.UserRoles?.find((r) => UUIDsEqual(r.RoleID, fp.RoleID));
883
+ if (!roleMatch) {
884
+ continue; // user does not hold this role
885
+ }
886
+ EntityFieldInfo.applyAccessToBuckets(fp.ReadAccess, allow, deny, 'CanRead');
887
+ EntityFieldInfo.applyAccessToBuckets(fp.UpdateAccess, allow, deny, 'CanUpdate');
888
+ EntityFieldInfo.applyAccessToBuckets(fp.CreateAccess, allow, deny, 'CanCreate');
889
+ }
890
+ const effective = {
891
+ CanRead: allow.CanRead && !deny.CanRead,
892
+ CanUpdate: allow.CanUpdate && !deny.CanUpdate,
893
+ CanCreate: allow.CanCreate && !deny.CanCreate,
894
+ };
895
+ // Read is required for Update and Create. A CHECK constraint enforces this per ROW and
896
+ // cannot enforce it here: role A granting Read+Update and role B denying Read are each
897
+ // individually legal, yet a user holding both aggregates to read-denied +
898
+ // update-allowed. This clamp is what makes that combination unreachable at runtime.
899
+ if (!effective.CanRead) {
900
+ effective.CanUpdate = false;
901
+ effective.CanCreate = false;
902
+ }
903
+ return effective;
904
+ }
905
+ /**
906
+ * Folds one trinary verb into the Allow/Deny buckets. Anything unrecognized is treated as
907
+ * `No Access`, so a value reaching here outside the CHECK constraint grants nothing.
908
+ */
909
+ static applyAccessToBuckets(access, allow, deny, verb) {
910
+ switch ((access ?? '').trim().toLowerCase()) {
911
+ case 'allow':
912
+ allow[verb] = true;
913
+ break;
914
+ case 'deny':
915
+ deny[verb] = true;
916
+ break;
917
+ default:
918
+ break; // 'No Access', and anything unrecognized: neutral
919
+ }
920
+ }
511
921
  /**
512
922
  * Returns the ValueListType using the EntityFieldValueListType enum.
513
923
  */
@@ -878,6 +1288,42 @@ export class EntityFieldInfo extends BaseInfo {
878
1288
  this.IsPrimaryKey ||
879
1289
  this.IsSpecialDateField;
880
1290
  }
1291
+ /**
1292
+ * True when {@link ExtendedType} is any Geo* tag (`Geo`, `GeoLatitude`, `GeoAddress`, …).
1293
+ * Used by maps, distance, and GeoCodeSyncService. Display-only virtuals still count.
1294
+ */
1295
+ get IsGeoExtendedType() {
1296
+ const t = this.ExtendedType;
1297
+ return typeof t === 'string' && t.startsWith('Geo');
1298
+ }
1299
+ /**
1300
+ * A Geo* field that can be written on Save. GeoCodeSyncService only runs when the
1301
+ * entity has at least one of these. Virtual / AllowUpdateAPI=0 fields (PrimaryAddress*,
1302
+ * `__mj_Latitude`, embedded `__mj_Latitude_{FK}`) are display-only — maps still use them.
1303
+ */
1304
+ get IsWritableGeoField() {
1305
+ return this.IsGeoExtendedType && !this.IsVirtual && !!this.AllowUpdateAPI;
1306
+ }
1307
+ /**
1308
+ * Native (table) latitude column — `ExtendedType=GeoLatitude`, or legacy `Geo` named Latitude.
1309
+ */
1310
+ get IsNativeLatitudeField() {
1311
+ if (this.IsVirtual)
1312
+ return false;
1313
+ if (this.ExtendedType === 'GeoLatitude')
1314
+ return true;
1315
+ return this.ExtendedType === 'Geo' && /^lat(itude)?$/i.test(this.Name);
1316
+ }
1317
+ /**
1318
+ * Native (table) longitude column — `ExtendedType=GeoLongitude`, or legacy `Geo` named Long*.
1319
+ */
1320
+ get IsNativeLongitudeField() {
1321
+ if (this.IsVirtual)
1322
+ return false;
1323
+ if (this.ExtendedType === 'GeoLongitude')
1324
+ return true;
1325
+ return this.ExtendedType === 'Geo' && /^(lng|lon|long|longitude)$/i.test(this.Name);
1326
+ }
881
1327
  /**
882
1328
  * Helper method that returns true if the field is one of the special reserved MJ date fields for tracking CreatedAt and UpdatedAt timestamps as well as the DeletedAt timestamp used for entities that
883
1329
  * have DeleteType=Soft. This is only used when the entity has TrackRecordChanges=1 or for entities where DeleteType=Soft
@@ -1419,6 +1865,7 @@ export class EntityFieldInfo extends BaseInfo {
1419
1865
  * Flag to track if RelatedEntityJoinFieldsConfig parsing failed to avoid repeated parse attempts on bad JSON.
1420
1866
  */
1421
1867
  this._relatedEntityJoinFieldsFailedParsing = false;
1868
+ this._FieldPermissions = [];
1422
1869
  /** Memoized {@link TSType} — `Type` is immutable after metadata load, so the classification never changes. */
1423
1870
  this._tsType = undefined;
1424
1871
  /**
@@ -1441,6 +1888,15 @@ export class EntityFieldInfo extends BaseInfo {
1441
1888
  this._EntityFieldValues = [];
1442
1889
  this._entityFieldValuesConstructed = true;
1443
1890
  }
1891
+ // Field-level security records. Constructed eagerly rather than lazily (unlike
1892
+ // EntityFieldValues above) because the array is empty for virtually every field in
1893
+ // every deployment — there is no ~36,000-object construction cost to defer, and
1894
+ // HasFieldPermissions is read on enforcement paths where a lazy hydration check
1895
+ // would cost more than the construction it avoids.
1896
+ const efp = initData.EntityFieldPermissions || initData._FieldPermissions || initData.FieldPermissions;
1897
+ if (efp && efp.length > 0) {
1898
+ this._FieldPermissions = efp.map((p) => new EntityFieldPermissionInfo(p));
1899
+ }
1444
1900
  }
1445
1901
  }
1446
1902
  /**
@@ -1577,6 +2033,40 @@ export class EntityInfo extends BaseInfo {
1577
2033
  const layered = this.GeneratedBaseViewName?.trim();
1578
2034
  return !!layered && layered.toLowerCase() !== (this.BaseView ?? '').toLowerCase();
1579
2035
  }
2036
+ /**
2037
+ * Parsed SubtypeSelector configuration, if configured.
2038
+ */
2039
+ get SubtypeSelectorConfig() {
2040
+ if (this._subtypeSelectorConfig === undefined) {
2041
+ if (this.SubtypeSelector && typeof this.SubtypeSelector === 'string') {
2042
+ try {
2043
+ const parsed = JSON.parse(this.SubtypeSelector);
2044
+ if (parsed && typeof parsed['Path'] === 'string' && parsed['Path'].trim().length > 0) {
2045
+ this._subtypeSelectorConfig = { Path: parsed['Path'].trim() };
2046
+ }
2047
+ else {
2048
+ LogError(`EntityInfo '${this.Name}': SubtypeSelector JSON must contain a non-empty 'Path' string property. Found: ${this.SubtypeSelector}`);
2049
+ this._subtypeSelectorConfig = null;
2050
+ }
2051
+ }
2052
+ catch (err) {
2053
+ LogError(`EntityInfo '${this.Name}': failed to parse SubtypeSelector JSON '${this.SubtypeSelector}': ${err instanceof Error ? err.message : String(err)}`);
2054
+ this._subtypeSelectorConfig = null;
2055
+ }
2056
+ }
2057
+ else {
2058
+ this._subtypeSelectorConfig = null;
2059
+ }
2060
+ }
2061
+ return this._subtypeSelectorConfig;
2062
+ }
2063
+ /**
2064
+ * True when at least one field is a writable Geo* source (street and/or native lat/lng).
2065
+ * Person/Org PrimaryAddress* are virtual display fields and do **not** count.
2066
+ */
2067
+ get HasWritableGeoSourceFields() {
2068
+ return (this.Fields ?? []).some(f => f.IsWritableGeoField);
2069
+ }
1580
2070
  /**
1581
2071
  * Optional JSON configuration bag (shape = {@link IEntityConfiguration}).
1582
2072
  * Nested `UI.Form` holds generated-form chrome: layout, auto left-nav
@@ -1611,6 +2101,140 @@ export class EntityInfo extends BaseInfo {
1611
2101
  get ConfigurationObject() {
1612
2102
  return this.Configuration;
1613
2103
  }
2104
+ /**
2105
+ * The set of field names this user may NOT READ on this entity — the per-request primitive
2106
+ * every field-security enforcement point is built on.
2107
+ *
2108
+ * Compute this ONCE per (entity, user) per request and pass the Set into any row loop.
2109
+ * `GetUserFieldPermissions` is the per-FIELD primitive; calling it per row costs
2110
+ * `fields x rows` aggregations (40,000 for a 1,000-row x 40-column result), each of which
2111
+ * re-scans `user.UserRoles` and allocates. A Set lookup costs neither.
2112
+ *
2113
+ * Names are lowercased so callers can match case-insensitively, consistent with
2114
+ * {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when the entity has
2115
+ * field security switched off and when the user is denied nothing, so callers can treat
2116
+ * `size === 0` as the single "nothing to do" condition.
2117
+ */
2118
+ GetDeniedReadFields(user) {
2119
+ return this.getDeniedFields(user, (p) => !p.CanRead);
2120
+ }
2121
+ /**
2122
+ * Whether this user may READ the named field — the single-field question, answered the same
2123
+ * way {@link GetDeniedReadFields} answers it in bulk.
2124
+ *
2125
+ * Exists for **display code that is about to read a value it did not choose**: a form
2126
+ * toolbar rendering the entity's Name field, an FK control rendering the joined display
2127
+ * column, an IS-A card walking a sibling record's fields. `BaseEntity.Get()` throws for a
2128
+ * denied field, so those call sites have to ask before they read or they take out the whole
2129
+ * screen instead of hiding one value.
2130
+ *
2131
+ * This is a PREDICATE, deliberately — not a value accessor that quietly returns nothing.
2132
+ * The caller still decides what to render in place of the value, which is the part that
2133
+ * differs per surface and should not be hidden inside a getter.
2134
+ *
2135
+ * **Fails open** on a missing user, a missing field name, or an entity with field security
2136
+ * switched off — matching `BaseEntity`'s own gate and `MjFormFieldComponent`. The server is
2137
+ * the real boundary; a UI that blanked out fields because no user had resolved yet would be
2138
+ * worse than one that shows them.
2139
+ *
2140
+ * PERFORMANCE: this delegates to {@link GetDeniedReadFields}, which walks every field on the
2141
+ * entity. Fine for the handful of chrome reads it exists for; do NOT call it per row in a
2142
+ * grid loop — compute the denied set once and test against it.
2143
+ *
2144
+ * @param fieldName the field about to be read
2145
+ * @param user the acting user
2146
+ */
2147
+ IsFieldReadableByUser(fieldName, user) {
2148
+ if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2149
+ return true;
2150
+ }
2151
+ return !this.GetDeniedReadFields(user).has(fieldName.trim().toLowerCase());
2152
+ }
2153
+ /**
2154
+ * Whether this user may UPDATE the named field. Companion to
2155
+ * {@link IsFieldReadableByUser}, for UI that needs to render a control read-only rather than
2156
+ * let a user type into something the server will reject on save. Fails open on the same
2157
+ * three conditions.
2158
+ */
2159
+ IsFieldUpdatableByUser(fieldName, user) {
2160
+ if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2161
+ return true;
2162
+ }
2163
+ return !this.GetDeniedUpdateFields(user).has(fieldName.trim().toLowerCase());
2164
+ }
2165
+ /**
2166
+ * Whether this user may supply a value for the named field when CREATING a record.
2167
+ * Companion to {@link IsFieldReadableByUser}. Fails open on the same three conditions.
2168
+ *
2169
+ * Note the server does not REJECT a create-denied value — it drops it and takes the column
2170
+ * default. So a UI that leaves such a field editable on a new record silently discards what
2171
+ * the user typed, which is the case this exists to prevent.
2172
+ */
2173
+ IsFieldCreatableByUser(fieldName, user) {
2174
+ if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
2175
+ return true;
2176
+ }
2177
+ return !this.GetDeniedCreateFields(user).has(fieldName.trim().toLowerCase());
2178
+ }
2179
+ /**
2180
+ * The set of field names this user may NOT UPDATE on this entity. Same per-request
2181
+ * precompute contract as {@link GetDeniedReadFields}.
2182
+ *
2183
+ * A field can be readable and not updatable. The reverse cannot happen — Read is required
2184
+ * for Update — so denied-read is always a subset of denied-update, but ask for the set you
2185
+ * actually need rather than relying on that.
2186
+ */
2187
+ GetDeniedUpdateFields(user) {
2188
+ return this.getDeniedFields(user, (p) => !p.CanUpdate);
2189
+ }
2190
+ /**
2191
+ * The set of field names this user may NOT supply a value for when CREATING a record. Same
2192
+ * per-request precompute contract as {@link GetDeniedReadFields}.
2193
+ *
2194
+ * Unlike the update set, this does not drive a rejection: a value supplied for a
2195
+ * create-denied field is dropped and the column takes its default.
2196
+ */
2197
+ GetDeniedCreateFields(user) {
2198
+ return this.getDeniedFields(user, (p) => !p.CanCreate);
2199
+ }
2200
+ /**
2201
+ * Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
2202
+ * {@link GetDeniedCreateFields}, short-circuiting on {@link EnableFieldLevelSecurity}.
2203
+ *
2204
+ * Every field is aggregated, including those carrying no permission records — on an enabled
2205
+ * entity those are denied. Unrestrictable fields (primary keys, `__mj_` columns) come back
2206
+ * open, decided inside `GetUserFieldPermissions` rather than skipped here.
2207
+ *
2208
+ * **Carries BOTH `Name` and `CodeName`**, because the callers do not all live in the same key
2209
+ * space and a set holding only one of them silently no-ops in the other. `BaseEntity` and the
2210
+ * predicate gate ask about field *Names*; the row projections
2211
+ * (`ProviderBase.OmitFieldsFromRows`, the Record Changes payload projector) match against a
2212
+ * *row's own keys*, and rows are keyed by `CodeName` — `getRunTimeViewFieldString` emits
2213
+ * `[Name] AS [CodeName]` whenever the two differ, and `CodeNameFromString` replaces every
2214
+ * `[^a-zA-Z0-9_]` with `_`. So for a column named `Base Salary` a Name-only set holds
2215
+ * `base salary` while the rows are keyed `Base_Salary`, nothing matches, and the denied values
2216
+ * are returned in full. Every shipped MJ field name is already a valid identifier, so the two
2217
+ * coincide throughout core and no fixture caught this; it needs a customer entity with a
2218
+ * column like `Base Salary` or `Emp #` — which is the population this feature exists for.
2219
+ *
2220
+ * Widening cannot over-deny. The only way an extra entry could catch an innocent field is if a
2221
+ * DENIED field's `CodeName` equalled a different, permitted field's `Name` — but two fields
2222
+ * that collide on `CodeName` already collide on their generated property, which is not a
2223
+ * schema CodeGen can emit.
2224
+ */
2225
+ getDeniedFields(user, isDenied) {
2226
+ const denied = new Set();
2227
+ if (!this.EnableFieldLevelSecurity) {
2228
+ return denied;
2229
+ }
2230
+ for (const field of this._Fields) {
2231
+ if (isDenied(field.GetUserFieldPermissions(user, true))) {
2232
+ denied.add(field.Name.trim().toLowerCase());
2233
+ denied.add(field.CodeName.trim().toLowerCase());
2234
+ }
2235
+ }
2236
+ return denied;
2237
+ }
1614
2238
  /**
1615
2239
  * O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
1616
2240
  * on hot paths — it builds a lowercased+trimmed `Map` once (lazily) and reuses it.
@@ -1638,6 +2262,12 @@ export class EntityInfo extends BaseInfo {
1638
2262
  /**
1639
2263
  * Returns the primary key field for the entity. For entities with a composite primary key, use the PrimaryKeys property which returns all.
1640
2264
  * In the case of a composite primary key, the PrimaryKey property will return the first field in the sequence of the primary key fields.
2265
+ *
2266
+ * This is a single-column convenience for the places MJ is single-column *by design* — foreign-key
2267
+ * targets, keyset `ORDER BY`, IS-A shared keys, and the bare-value shorthand `CompositeKey.LoadFromURLSegment`
2268
+ * accepts. Do not use it to *construct* a load key for an arbitrary entity: that silently drops every
2269
+ * column but the first on a composite key. Build keys with `CompositeKey.FromURLSegment(entityInfo, recordId)`
2270
+ * or `CompositeKey.FromEntityRecord(entityInfo, row)`, which honor all of `PrimaryKeys`.
1641
2271
  */
1642
2272
  get FirstPrimaryKey() {
1643
2273
  if (this._firstPrimaryKeyCache === undefined) {
@@ -2063,7 +2693,7 @@ export class EntityInfo extends BaseInfo {
2063
2693
  const allow = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
2064
2694
  const deny = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
2065
2695
  for (const ep of permissionList) {
2066
- const isDeny = (ep.Type || 'Allow').trim().toLowerCase() === 'deny';
2696
+ const isDeny = ep.IsDeny;
2067
2697
  const bucket = isDeny ? deny : allow;
2068
2698
  bucket.CanCreate = bucket.CanCreate || !!ep.CanCreate;
2069
2699
  bucket.CanRead = bucket.CanRead || !!ep.CanRead;
@@ -2089,7 +2719,15 @@ export class EntityInfo extends BaseInfo {
2089
2719
  }
2090
2720
  }
2091
2721
  /**
2092
- * Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not
2722
+ * Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not.
2723
+ *
2724
+ * A permission row confers an exemption only for an operation it GRANTS (the matching `Can*`
2725
+ * flag is true) and leaves unfiltered. A row that does not grant the operation has no filter
2726
+ * for it either, and that absence means "not applicable", not "unrestricted" — so it must not
2727
+ * lift a filter that another of the user's roles binds. Without the `Can*` check, a role that
2728
+ * grants only reads (CanCreate=false, hence CreateRLSFilterID=null) made every holder of that
2729
+ * role exempt from CREATE row-level security, which is the shape of the 'UI' role every
2730
+ * authenticated user holds on nearly every entity.
2093
2731
  * @param user
2094
2732
  * @param type
2095
2733
  * @returns
@@ -2097,23 +2735,26 @@ export class EntityInfo extends BaseInfo {
2097
2735
  UserExemptFromRowLevelSecurity(user, type) {
2098
2736
  for (let j = 0; j < this.Permissions.length; j++) {
2099
2737
  const ep = this.Permissions[j];
2738
+ if (ep.IsDeny) {
2739
+ continue; // a Deny row's Can* flags are denials, never grants — it cannot exempt anyone
2740
+ }
2100
2741
  const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
2101
2742
  if (roleMatch) { // user has this role
2102
2743
  switch (type) {
2103
2744
  case EntityPermissionType.Create:
2104
- if (!ep.CreateRLSFilterID)
2745
+ if (ep.CanCreate && !ep.CreateRLSFilterID)
2105
2746
  return true;
2106
2747
  break;
2107
2748
  case EntityPermissionType.Read:
2108
- if (!ep.ReadRLSFilterID)
2749
+ if (ep.CanRead && !ep.ReadRLSFilterID)
2109
2750
  return true;
2110
2751
  break;
2111
2752
  case EntityPermissionType.Update:
2112
- if (!ep.UpdateRLSFilterID)
2753
+ if (ep.CanUpdate && !ep.UpdateRLSFilterID)
2113
2754
  return true;
2114
2755
  break;
2115
2756
  case EntityPermissionType.Delete:
2116
- if (!ep.DeleteRLSFilterID)
2757
+ if (ep.CanDelete && !ep.DeleteRLSFilterID)
2117
2758
  return true;
2118
2759
  break;
2119
2760
  }
@@ -2122,7 +2763,18 @@ export class EntityInfo extends BaseInfo {
2122
2763
  return false; // if we get here, the user is NOT exempt from RLS for this Permission Type
2123
2764
  }
2124
2765
  /**
2125
- * Returns RLS security info attributes for a given user and permission type
2766
+ * Returns RLS security info attributes for a given user and permission type.
2767
+ *
2768
+ * Only permission rows that GRANT the operation contribute a filter: an Allow row whose matching
2769
+ * `Can*` flag is true. Deny rows are skipped outright — on a Deny row a set `Can*` flag means
2770
+ * "deny that operation", and a user carrying one fails the permission gate before this runs
2771
+ * (`GetUserPermisions` subtracts Deny from Allow), so reading it as a grant would be wrong even
2772
+ * though it is unreachable. The filters of a user's roles are OR'd together by the caller, so a filter collected
2773
+ * from a row that does not grant the operation would WIDEN the clause: a user granted Create by
2774
+ * role A (bound to filter F1) would create against `F1 OR F2` when role B keeps a leftover
2775
+ * `CreateRLSFilterID = F2` beside `CanCreate = false`. `GetUserPermisions` aggregates the flags
2776
+ * across roles, so such a user passes the permission gate on role A alone; nothing else stops
2777
+ * F2 from applying. A user with no granting row gets no clause here — and no permission either.
2126
2778
  * @param user
2127
2779
  * @param type
2128
2780
  * @returns
@@ -2131,24 +2783,27 @@ export class EntityInfo extends BaseInfo {
2131
2783
  const rlsList = [];
2132
2784
  for (let j = 0; j < this.Permissions.length; j++) {
2133
2785
  const ep = this.Permissions[j];
2786
+ if (ep.IsDeny) {
2787
+ continue; // never a grant — see the doc comment
2788
+ }
2134
2789
  const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
2135
2790
  if (roleMatch) { // user has this role
2136
2791
  let matchObject = null;
2137
2792
  switch (type) {
2138
2793
  case EntityPermissionType.Create:
2139
- if (ep.CreateRLSFilterID)
2794
+ if (ep.CanCreate && ep.CreateRLSFilterID)
2140
2795
  matchObject = ep.CreateRLSFilterObject;
2141
2796
  break;
2142
2797
  case EntityPermissionType.Read:
2143
- if (ep.ReadRLSFilterID)
2798
+ if (ep.CanRead && ep.ReadRLSFilterID)
2144
2799
  matchObject = ep.ReadRLSFilterObject;
2145
2800
  break;
2146
2801
  case EntityPermissionType.Update:
2147
- if (ep.UpdateRLSFilterID)
2802
+ if (ep.CanUpdate && ep.UpdateRLSFilterID)
2148
2803
  matchObject = ep.UpdateRLSFilterObject;
2149
2804
  break;
2150
2805
  case EntityPermissionType.Delete:
2151
- if (ep.DeleteRLSFilterID)
2806
+ if (ep.CanDelete && ep.DeleteRLSFilterID)
2152
2807
  matchObject = ep.DeleteRLSFilterObject;
2153
2808
  break;
2154
2809
  }
@@ -2284,7 +2939,7 @@ export class EntityInfo extends BaseInfo {
2284
2939
  }
2285
2940
  else {
2286
2941
  // currently we only support a single value for FOREIGN KEYS, so we can just grab the first value in the primary key
2287
- const firstKey = record.FirstPrimaryKey;
2942
+ const firstKey = record.FirstPrimaryKey; // first-pk-ok: FK target — a relationship's join field references one parent key column
2288
2943
  keyValue = firstKey.Value;
2289
2944
  //When creating a new record, the keyValue is null and the quotes are not needed
2290
2945
  quotes = keyValue && firstKey.NeedsQuotes ? "'" : '';
@@ -2324,7 +2979,7 @@ export class EntityInfo extends BaseInfo {
2324
2979
  if (rel)
2325
2980
  return EntityInfo.BuildRelationshipViewParams(record, rel);
2326
2981
  }
2327
- const firstKey = record.FirstPrimaryKey;
2982
+ const firstKey = record.FirstPrimaryKey; // first-pk-ok: FK target — a relationship's join field references one parent key column
2328
2983
  const keyValue = firstKey.Value;
2329
2984
  const quotes = keyValue && firstKey.NeedsQuotes ? "'" : '';
2330
2985
  const clauses = fields.map((field) => `[${field}] = ${quotes}${keyValue}${quotes}`);
@@ -2373,7 +3028,7 @@ export class EntityInfo extends BaseInfo {
2373
3028
  const explicit = relationship?.EntityKeyField?.trim();
2374
3029
  if (explicit)
2375
3030
  return record.Get(explicit);
2376
- const first = record.FirstPrimaryKey;
3031
+ const first = record.FirstPrimaryKey; // first-pk-ok: FK target — a relationship's join field references one parent key column
2377
3032
  if (first?.Name)
2378
3033
  return record.Get(first.Name);
2379
3034
  return first?.Value;
@@ -2654,6 +3309,12 @@ export class EntityInfo extends BaseInfo {
2654
3309
  * This flag is set on the **parent** entity and controls whether its children are exclusive.
2655
3310
  */
2656
3311
  this.AllowMultipleSubtypes = false;
3312
+ /**
3313
+ * Optional JSON configuration specifying declarative prospective subtype resolution on an entity.
3314
+ * Stored in the SubtypeSelector column of Entity (shape = IEntitySubtypeSelectorConfig).
3315
+ */
3316
+ this.SubtypeSelector = null;
3317
+ this._subtypeSelectorConfig = undefined;
2657
3318
  /**
2658
3319
  * Whether to audit when users access records from this entity
2659
3320
  */
@@ -2678,6 +3339,19 @@ export class EntityInfo extends BaseInfo {
2678
3339
  * client-side IndexedDB cache. Zero overhead on hot save/query paths.
2679
3340
  */
2680
3341
  this.AllowCaching = false;
3342
+ /**
3343
+ * Whether field-level (column-level) security is enforced for this entity.
3344
+ *
3345
+ * This is the single gate every field-security enforcement point checks first, and it is
3346
+ * explicit — never inferred from whether permission rows happen to exist. It is `false` for
3347
+ * nearly every entity in nearly every deployment, so enforcement collapses to one boolean
3348
+ * test: no field iteration, no aggregation, no allocation.
3349
+ *
3350
+ * Turning it on snapshots the entity's existing entity-level permissions into per-field
3351
+ * rows, so enabling changes no behavior until an administrator tightens a field. Turning it
3352
+ * off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
3353
+ */
3354
+ this.EnableFieldLevelSecurity = false;
2681
3355
  /**
2682
3356
  * Whether this entity is available through the GraphQL API
2683
3357
  */
@@ -2788,9 +3462,10 @@ export class EntityInfo extends BaseInfo {
2788
3462
  */
2789
3463
  this.FullTextSearchFunctionGenerated = true;
2790
3464
  /**
2791
- * When true, this entity supports geocoding CodeGen generates geo-aware subclass code,
2792
- * adds __mj_Latitude/__mj_Longitude virtual fields to the base view, and the UI shows
2793
- * a map view toggle. Auto-set by CodeGen when LLM detects geo-capable fields.
3465
+ * When true, this entity participates in geo **read** features: map view, distance
3466
+ * calculations, and similar. That is independent of whether GeoCodeSyncService runs
3467
+ * on Save the service only fires when {@link HasWritableGeoSourceFields} is true.
3468
+ * Auto-set by CodeGen when LLM detects geo-capable fields.
2794
3469
  */
2795
3470
  this.SupportsGeoCoding = false;
2796
3471
  /**
@@ -3053,7 +3728,7 @@ export class EntityInfo extends BaseInfo {
3053
3728
  er.sort((a, b) => {
3054
3729
  const aSeq = a.Sequence !== null && a.Sequence !== undefined ? a.Sequence : 999999;
3055
3730
  const bSeq = b.Sequence !== null && b.Sequence !== undefined ? b.Sequence : 999999;
3056
- return aSeq - bSeq;
3731
+ return (aSeq - bSeq) || ordinalCompare(a.RelatedEntity, b.RelatedEntity) || ordinalCompare(a.ID, b.ID);
3057
3732
  });
3058
3733
  }
3059
3734
  // now that we have prepared the er array by sorting it, if needed, let's load up the related entities