@memberjunction/server 6.1.2 → 6.1.4

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.
@@ -0,0 +1,177 @@
1
+ // The gate lives beside the subscription it protects, and that module carries type-graphql
2
+ // decorators — so the metadata shim has to be in place before it is imported.
3
+ import 'reflect-metadata';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import {
6
+ ConfigureRecordDataBroadcast,
7
+ MayBroadcastRecordData,
8
+ } from '../generic/CacheInvalidationResolver.js';
9
+
10
+ /**
11
+ * The cache-invalidation subscription is delivered to every connected client with no per-user
12
+ * filter, so anything `MayBroadcastRecordData` lets through is readable by every signed-in session
13
+ * regardless of row-level security or any tenant scoping a consumer applies on the read path.
14
+ *
15
+ * The important case is the DEFAULT: an untouched deployment must broadcast no row data at all,
16
+ * because that is the configuration everyone upgrades into.
17
+ */
18
+ describe('MayBroadcastRecordData', () => {
19
+ afterEach(() => ConfigureRecordDataBroadcast([]));
20
+
21
+ it('broadcasts nothing before anything has configured it', () => {
22
+ // No ConfigureRecordDataBroadcast call at all — the state a process starts in, and the state it
23
+ // stays in if bootstrap ever stops calling it. Failing closed there is the whole point.
24
+ expect(MayBroadcastRecordData('Users')).toBe(false);
25
+ expect(MayBroadcastRecordData('AI Models')).toBe(false);
26
+ });
27
+
28
+ it('broadcasts nothing when the deployment has not opted in', () => {
29
+ ConfigureRecordDataBroadcast([]);
30
+ expect(MayBroadcastRecordData('Users')).toBe(false);
31
+ });
32
+
33
+ it('broadcasts nothing when the config value is absent', () => {
34
+ ConfigureRecordDataBroadcast(undefined);
35
+ expect(MayBroadcastRecordData('Users')).toBe(false);
36
+ });
37
+
38
+ it('allows exactly the entities named, and no others', () => {
39
+ ConfigureRecordDataBroadcast(['AI Models']);
40
+ expect(MayBroadcastRecordData('AI Models')).toBe(true);
41
+ expect(MayBroadcastRecordData('Users')).toBe(false);
42
+ expect(MayBroadcastRecordData('AI Model Vendors')).toBe(false);
43
+ // The allowlisted name as a SUBSTRING of another entity must not carry it in. Written this way
44
+ // deliberately: 'AI Model Vendors' above does not contain 'AI Models', so it passes against a
45
+ // loose `includes` match too and proves nothing — a substring mutation survived it.
46
+ expect(MayBroadcastRecordData('Archived AI Models 2024')).toBe(false);
47
+ // ...and the reverse direction, where the entity name is a substring of the allowlisted one.
48
+ expect(MayBroadcastRecordData('AI Model')).toBe(false);
49
+ });
50
+
51
+ it('matches entity names case- and whitespace-insensitively', () => {
52
+ ConfigureRecordDataBroadcast([' ai models ']);
53
+ expect(MayBroadcastRecordData('AI Models')).toBe(true);
54
+ });
55
+
56
+ it("restores the previous broadcast-everything behaviour only on an explicit '*'", () => {
57
+ ConfigureRecordDataBroadcast(['*']);
58
+ expect(MayBroadcastRecordData('Users')).toBe(true);
59
+ expect(MayBroadcastRecordData('Organization Person Roles')).toBe(true);
60
+ });
61
+ });
62
+
63
+ /**
64
+ * Importing this module must not drag server config loading in with it.
65
+ *
66
+ * The first version of this change read `configInfo` at module scope. `config.ts` runs
67
+ * `loadConfig()` on import, so every test that touched the resolver — directly or through
68
+ * ResolverBase — died at import with "Configuration validation failed". CI caught it; this keeps
69
+ * it caught.
70
+ */
71
+ describe('CacheInvalidationResolver module surface', () => {
72
+ it('imports without any server configuration present', async () => {
73
+ await expect(import('../generic/CacheInvalidationResolver.js')).resolves.toBeDefined();
74
+ });
75
+ });
76
+
77
+ /**
78
+ * The allowlist is normalised where it enters, not where it is compared.
79
+ *
80
+ * Raised in review: the wildcard was matched raw while entity names were trimmed and lowercased,
81
+ * so a hand-edited `[' * ']` matched neither branch and silently opted nothing in — a config that
82
+ * reads as "broadcast everything" while the gate stayed fully closed.
83
+ */
84
+ describe('allowlist normalisation', () => {
85
+ afterEach(() => ConfigureRecordDataBroadcast([]));
86
+
87
+ it("honours a wildcard written with surrounding whitespace", () => {
88
+ ConfigureRecordDataBroadcast([' * ']);
89
+ expect(MayBroadcastRecordData('Users')).toBe(true);
90
+ });
91
+
92
+ it('ignores blank entries rather than letting one match something', () => {
93
+ ConfigureRecordDataBroadcast(['', ' ']);
94
+ expect(MayBroadcastRecordData('Users')).toBe(false);
95
+ // A blank entry must not become a wildcard by accident either.
96
+ expect(MayBroadcastRecordData('')).toBe(false);
97
+ });
98
+
99
+ it('still matches a normal name written with odd casing and padding', () => {
100
+ ConfigureRecordDataBroadcast([' AI MODELS ']);
101
+ expect(MayBroadcastRecordData('ai models')).toBe(true);
102
+ expect(MayBroadcastRecordData('Users')).toBe(false);
103
+ });
104
+ });
105
+
106
+ /**
107
+ * Entity-level delivery filtering, raised in review.
108
+ *
109
+ * An event for an entity a session cannot read at all tells that session the record exists, its
110
+ * stable key and when it changed — and the session could never have read it, so withholding costs
111
+ * nothing. This does NOT close the row-level case: two sessions that both hold read permission on
112
+ * an entity still see each other's keys. That is tracked separately and deliberately not faked
113
+ * green here.
114
+ */
115
+ describe('cacheInvalidationFilter', () => {
116
+ const event = (entityName: string) => ({
117
+ entityName,
118
+ primaryKeyValues: '[{"FieldName":"ID","Value":"r1"}]',
119
+ action: 'save',
120
+ sourceServerId: 's1',
121
+ timestamp: new Date(),
122
+ });
123
+ const session = (userRecord: unknown) => ({ userPayload: { userRecord } });
124
+
125
+ const withEntities = async (entities: Array<{ Name: string; CanRead: boolean }>) => {
126
+ vi.resetModules();
127
+ vi.doMock('@memberjunction/core', () => ({
128
+ Metadata: class {
129
+ get Entities() {
130
+ return entities.map((e) => ({
131
+ Name: e.Name,
132
+ GetUserPermisions: () => ({ CanRead: e.CanRead }),
133
+ }));
134
+ }
135
+ // The real one is case-insensitive and trims — the filter relies on that, so the mock does too.
136
+ EntityByName(name: string) {
137
+ const key = name.trim().toLowerCase();
138
+ return this.Entities.find((e) => e.Name.toLowerCase() === key);
139
+ }
140
+ },
141
+ }));
142
+ return (await import('../generic/CacheInvalidationResolver.js')).cacheInvalidationFilter;
143
+ };
144
+
145
+ afterEach(() => vi.doUnmock('@memberjunction/core'));
146
+
147
+ it('delivers an event for an entity the session may read', async () => {
148
+ const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
149
+ expect(filter({ payload: event('AI Models') as never, context: session({ ID: 'u1' }) })).toBe(true);
150
+ });
151
+
152
+ it('withholds an event for an entity the session may NOT read', async () => {
153
+ const filter = await withEntities([{ Name: 'Users', CanRead: false }]);
154
+ expect(filter({ payload: event('Users') as never, context: session({ ID: 'u1' }) })).toBe(false);
155
+ });
156
+
157
+ it('fails closed when the connection carries no user', async () => {
158
+ // onConnect rejects a socket whose token does not validate, so this should not arise — and it
159
+ // is the case least deserving of an unfiltered firehose if it does.
160
+ const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
161
+ expect(filter({ payload: event('AI Models') as never, context: undefined })).toBe(false);
162
+ expect(filter({ payload: event('AI Models') as never, context: session(undefined) })).toBe(false);
163
+ });
164
+
165
+ it('delivers when the entity is unknown to this server, rather than silently going stale', async () => {
166
+ // Deliberately the opposite choice: a name absent from metadata cannot be permission-checked,
167
+ // and withholding would stop invalidating a legitimately cacheable entity — a correctness bug
168
+ // in place of a disclosure one.
169
+ const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
170
+ expect(filter({ payload: event('Some Entity This Server Never Heard Of') as never, context: session({ ID: 'u1' }) })).toBe(true);
171
+ });
172
+
173
+ it('withholds an event with no entity name', async () => {
174
+ const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
175
+ expect(filter({ payload: { ...event(''), entityName: '' } as never, context: session({ ID: 'u1' }) })).toBe(false);
176
+ });
177
+ });
package/src/config.ts CHANGED
@@ -230,6 +230,29 @@ const cacheSettingsSchema = z.object({
230
230
  evictionSweepIntervalSeconds: z.number().optional().default(300),
231
231
  /** Enable verbose cache logging (hits, misses, evictions). Default: false. */
232
232
  verboseLogging: z.boolean().optional().default(false),
233
+ /**
234
+ * Entity names whose FULL ROW may ride along with a cache-invalidation broadcast.
235
+ *
236
+ * The cache-invalidation subscription is delivered to EVERY connected client with no per-user
237
+ * filter (see CacheInvalidationResolver), so any row named here is disclosed to every signed-in
238
+ * session, whatever row-level security or tenant scoping would otherwise apply to reading it.
239
+ *
240
+ * Defaults to `[]`: invalidation still carries the entity name and primary key, which is all a
241
+ * client needs to evict, and the client re-fetches through the normal read path where access
242
+ * control applies. Listing an entity re-enables the apply-in-place optimisation for it — correct
243
+ * only for reference data every signed-in user is allowed to read.
244
+ *
245
+ * What the re-fetch costs depends on the consumer. `ConversationEngine` re-reads the ONE record
246
+ * by primary key. `BaseEngine` (every engine subclass with `AutoRefresh`, the default) applies a
247
+ * remote save in place only when the row is present, so without it a remote save falls through to
248
+ * a full reload of each matching config — a `RunView` of that entity, not a keyed read. Remote
249
+ * deletes still apply in place from the primary key. Engine-cached reference entities that every
250
+ * signed-in user may read are the ones worth listing here.
251
+ *
252
+ * `['*']` opts every entity in, restoring the previous behaviour. Only safe on a deployment where
253
+ * every signed-in user may read every row of every entity.
254
+ */
255
+ recordDataBroadcastEntities: z.array(z.string()).optional().default([]),
233
256
  });
234
257
 
235
258
  const loggingSettingsSchema = z.object({
@@ -1,7 +1,57 @@
1
1
  import { Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
2
+ import { Metadata, UserInfo } from '@memberjunction/core';
2
3
 
3
4
  export const CACHE_INVALIDATION_TOPIC = 'CACHE_INVALIDATION';
4
5
 
6
+ /**
7
+ * Entities whose rows may ride along with an invalidation event. Empty until the server entry point
8
+ * supplies the configured list, so the default — including in any process that never configures it
9
+ * — is to broadcast nothing.
10
+ */
11
+ let allowedRecordDataEntities: readonly string[] = [];
12
+
13
+ /**
14
+ * Supply the configured allowlist. Called once from the server entry point during bootstrap.
15
+ *
16
+ * INJECTED rather than read from `configInfo` here, deliberately. `config.ts` runs `loadConfig()` at
17
+ * module scope, so importing it from this module would drag full config validation into every
18
+ * import chain that touches the resolver — including unit tests, which have no server config and
19
+ * would fail at import with "Configuration validation failed". This module deliberately imports
20
+ * nothing from MJServer itself — only type-graphql and `@memberjunction/core` (for the entity
21
+ * permission check in the subscription filter), neither of which loads config.
22
+ */
23
+ export function ConfigureRecordDataBroadcast(entities: readonly string[] | undefined | null): void {
24
+ // Normalised HERE, once, rather than at each comparison. The wildcard used to be matched raw
25
+ // (`includes('*')`) while entity names were trimmed and lowercased two lines below, so a
26
+ // hand-edited `[' * ']` matched neither branch and silently opted nothing in — a config that
27
+ // looks like it disables the gate while leaving it fully on. Empty entries are dropped so a
28
+ // stray `''` cannot match an entity whose name is somehow blank.
29
+ allowedRecordDataEntities = (entities ?? [])
30
+ .map((e) => e.trim().toLowerCase())
31
+ .filter((e) => e.length > 0);
32
+ }
33
+
34
+ /**
35
+ * May the full row of `entityName` be broadcast alongside its cache-invalidation event?
36
+ *
37
+ * The subscription below is filtered only by ENTITY-level read permission, so anything included
38
+ * here is disclosed to every signed-in session that may read the entity at all — regardless of
39
+ * row-level security or any tenant scoping a consumer applies on the read path, neither of which
40
+ * this push traverses. The answer is therefore NO unless a deployment has explicitly opted the
41
+ * entity in via `cacheSettings.recordDataBroadcastEntities`.
42
+ *
43
+ * Callers that get `false` simply omit `recordData`; the event still carries the entity name and
44
+ * primary key, so clients evict and re-fetch through the normal access-controlled read path.
45
+ *
46
+ * Matching is exact (trimmed, case-insensitive) rather than substring: a loose match would let an
47
+ * allowlisted name drag in every entity whose name contains it.
48
+ */
49
+ export function MayBroadcastRecordData(entityName: string): boolean {
50
+ if (allowedRecordDataEntities.length === 0) return false;
51
+ if (allowedRecordDataEntities.includes('*')) return true;
52
+ return allowedRecordDataEntities.includes(entityName.trim().toLowerCase());
53
+ }
54
+
5
55
  @ObjectType()
6
56
  export class CacheInvalidationNotification {
7
57
  @Field(() => String)
@@ -40,15 +90,101 @@ export interface CacheInvalidationPayload {
40
90
  recordData?: string;
41
91
  }
42
92
 
93
+ /**
94
+ * What the subscription filter needs off the WS connection.
95
+ *
96
+ * Declared structurally rather than importing MJServer's `UserPayload`, which reaches `types.ts`
97
+ * and through it `auth/index.js` and config. Keeping this module's imports minimal is not fussiness
98
+ * — importing `configInfo` here is what previously broke every unit test that touched the resolver,
99
+ * and the import-surface test in this package exists to keep it that way.
100
+ */
101
+ export interface CacheInvalidationFilterContext {
102
+ userPayload?: { userRecord?: unknown };
103
+ }
104
+
105
+ /**
106
+ * Should this subscriber hear about this event at all?
107
+ *
108
+ * Entity-level narrowing, raised in review: an event for an entity a session has no read
109
+ * permission on tells that session the record exists, its stable key, and when it changed — and
110
+ * the session could never have read it anyway, so nothing is lost by withholding it. This is a
111
+ * monotonic reduction in disclosure that needs no tenancy model, which is why it is here and the
112
+ * row-level question is not.
113
+ *
114
+ * What it does NOT close: two sessions that both hold read permission on an entity still see each
115
+ * other's keys, because what separates them is row-level. That predicate is tracked separately —
116
+ * see the class docblock below.
117
+ *
118
+ * `GetUserPermisions` is an in-memory match of the entity's permission rows against the user's
119
+ * roles, so this stays cheap enough to run per subscriber per event. It is not free: it runs for
120
+ * every connected socket on every save, so keep it free of I/O.
121
+ */
122
+ export function cacheInvalidationFilter(data: {
123
+ payload: CacheInvalidationPayload;
124
+ context: CacheInvalidationFilterContext | undefined;
125
+ }): boolean {
126
+ const user = data.context?.userPayload?.userRecord as UserInfo | undefined;
127
+ // FAIL CLOSED on identity, matching `statusUpdatesFilter`. `onConnect` rejects a socket whose
128
+ // token does not validate, so a subscriber with no user record should not exist; if one does,
129
+ // it is the case least deserving of an unfiltered firehose.
130
+ if (!user) {
131
+ return false;
132
+ }
133
+
134
+ const entityName = data.payload?.entityName;
135
+ if (!entityName) {
136
+ return false;
137
+ }
138
+
139
+ // A pub/sub subscription filter has no per-request provider to thread: it runs on the server's
140
+ // own metadata, which is the one place the entity's permission rows can be answered from.
141
+ const entity = new Metadata().EntityByName(entityName); // global-provider-ok: subscription filter — server-wide metadata, no request-scoped provider exists here
142
+ // DELIVER when the entity is unknown here, deliberately — the opposite of the choice above.
143
+ // A name absent from metadata cannot be permission-checked, and withholding would silently
144
+ // stop invalidating a legitimately cacheable entity, which is a correctness bug rather than a
145
+ // disclosure one. All it discloses is the name and key of something this server cannot
146
+ // describe.
147
+ if (!entity) {
148
+ return true;
149
+ }
150
+
151
+ return entity.GetUserPermisions(user).CanRead;
152
+ }
153
+
43
154
  @Resolver()
44
155
  export class CacheInvalidationResolver {
45
156
  /**
46
- * Subscription that broadcasts cache invalidation events to ALL connected clients.
47
- * No session filter — every browser connected via WebSocket receives every event.
48
- * This enables cross-server cache invalidation to propagate to browser clients.
157
+ * Subscription that broadcasts cache invalidation events to connected clients, so cross-server
158
+ * cache invalidation reaches browsers.
159
+ *
160
+ * Delivery is filtered only by ENTITY-level read permission (see {@link cacheInvalidationFilter}).
161
+ * Every session that may read an entity at all still receives every event for it, from every
162
+ * other session and tenant.
163
+ *
164
+ * Because that narrowing stops well short of row level, `RecordData` is only populated for entities a
165
+ * deployment has opted in through `cacheSettings.recordDataBroadcastEntities` — see
166
+ * {@link MayBroadcastRecordData}, applied at both publish sites.
167
+ *
168
+ * `EntityName`, `PrimaryKeyValues`, `Action` and `Timestamp` ARE still broadcast to every
169
+ * session, and that is a disclosure, not a safe residue. An earlier version of this comment
170
+ * called them "safe to broadcast" on the grounds that reading the record still goes through
171
+ * the access-controlled path — true of the row's CONTENTS, and beside the point for the
172
+ * metadata itself. A session with no relationship to a record still learns that it exists,
173
+ * its stable key, and every time it changes; across entities it learns the cadence and volume
174
+ * of another tenant's activity.
175
+ *
176
+ * The key is kept deliberately, because consumers need it to re-read the record they were
177
+ * told about (see `ResolveEntityEventKey` in @memberjunction/core) and because the alternative
178
+ * — a coarse "something in this entity changed" — forces whole-entity invalidation on the
179
+ * hottest write paths. That is a trade, not an absence of cost. Narrowing it means filtering
180
+ * delivery per subscriber, which needs an authorization predicate cheap enough to run per
181
+ * subscriber per event; entity-level permissions (`EntityInfo.GetUserPermisions`, an in-memory
182
+ * role match) would cover part of it, row-level tenancy would not.
49
183
  */
50
184
  @Subscription(() => CacheInvalidationNotification, {
51
185
  topics: CACHE_INVALIDATION_TOPIC,
186
+ filter: (data: { payload: CacheInvalidationPayload; context: CacheInvalidationFilterContext | undefined }) =>
187
+ cacheInvalidationFilter(data),
52
188
  })
53
189
  cacheInvalidation(
54
190
  @Root() payload: CacheInvalidationPayload
@@ -50,9 +50,16 @@ export class DeleteOptionsInput {
50
50
  * schema-sync gate requires `DeleteOptionsInput` to carry every `EntityDeleteOptions` field;
51
51
  * {@link DeleteOptionsInput.SanitizeFromWire} forces it back to false on every request. See
52
52
  * that method for why.
53
+ *
54
+ * **Optional on the wire, on purpose.** This field did not exist in 5.51.x, and every other
55
+ * field here is required, so making it required too turned a 6.1 upgrade into a hard failure
56
+ * for every client that spells out `options___` on a delete (`Field
57
+ * "DeleteOptionsInput.SkipRecordChanges" of required type "Boolean!" was not provided`). A
58
+ * 5.51 client that omits it and a 6.1 client that sends `false` mean the same thing, and the
59
+ * server forces it to `false` regardless, so `null`/absent defaults to `false` here.
53
60
  */
54
- @Field(() => Boolean)
55
- SkipRecordChanges: boolean;
61
+ @Field(() => Boolean, { nullable: true, defaultValue: false })
62
+ SkipRecordChanges?: boolean;
56
63
 
57
64
  /**
58
65
  * Strips wire-unreachable capabilities from client-supplied delete options.
@@ -40,7 +40,7 @@ import { SQLParser } from '@memberjunction/sql-parser';
40
40
  import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
41
41
  import { EncryptionEngine } from '@memberjunction/encryption';
42
42
  import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
43
- import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
43
+ import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
44
44
  import { PubSubManager } from './PubSubManager.js';
45
45
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
46
46
  import { Subscription } from 'rxjs';
@@ -1449,16 +1449,24 @@ export class ResolverBase {
1449
1449
  * Publishes a CACHE_INVALIDATION event to connected browser clients after a successful
1450
1450
  * entity save or delete. Includes the originSessionId so the originating browser can
1451
1451
  * skip redundant re-fetches (it already handled the event locally).
1452
+ *
1453
+ * The row itself rides along ONLY for entities opted in via
1454
+ * `cacheSettings.recordDataBroadcastEntities` — this event reaches every connected client
1455
+ * unfiltered, so the row would otherwise be readable by sessions that could not read the record.
1452
1456
  */
1453
1457
  protected PublishCacheInvalidation(entityObject: BaseEntity, action: 'save' | 'delete', userPayload: UserPayload): void {
1458
+ const entityName = entityObject.EntityInfo.Name;
1454
1459
  PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
1455
- entityName: entityObject.EntityInfo.Name,
1460
+ entityName,
1456
1461
  primaryKeyValues: JSON.stringify(entityObject.PrimaryKey.KeyValuePairs),
1457
1462
  action,
1458
1463
  sourceServerId: MJGlobal.Instance.ProcessUUID,
1459
1464
  timestamp: new Date(),
1460
1465
  originSessionId: userPayload?.sessionId || null,
1461
- recordData: action === 'save' ? JSON.stringify(entityObject.GetAll()) : undefined,
1466
+ recordData:
1467
+ action === 'save' && MayBroadcastRecordData(entityName)
1468
+ ? JSON.stringify(entityObject.GetAll())
1469
+ : undefined,
1462
1470
  });
1463
1471
  }
1464
1472
 
package/src/index.ts CHANGED
@@ -67,7 +67,7 @@ import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
67
67
  import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
68
68
  import { SessionJanitor } from './agentSessions/index.js';
69
69
  import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
70
- import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
70
+ import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData, ConfigureRecordDataBroadcast } from './generic/CacheInvalidationResolver.js';
71
71
  import { ConnectorFactory, IntegrationEngine, IntegrationSyncOptions } from '@memberjunction/integration-engine';
72
72
  import { CronExpressionHelper } from '@memberjunction/scheduling-engine';
73
73
  import {
@@ -896,6 +896,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
896
896
  // publish hook above so the first RSU event also reaches live subscribers.
897
897
  RegisterRSUProgressBridge();
898
898
 
899
+ // Hand the resolver its allowlist before anything can publish. It cannot read configInfo itself:
900
+ // config.ts loads and validates at module scope, so importing it there would pull full config
901
+ // validation into every import chain that touches the resolver, unit tests included.
902
+ ConfigureRecordDataBroadcast(configInfo.cacheSettings?.recordDataBroadcastEntities);
903
+
899
904
  // Global listener: broadcast CACHE_INVALIDATION to all browser clients whenever
900
905
  // ANY BaseEntity save/delete occurs on this server — regardless of whether it
901
906
  // originated from a GraphQL mutation or internal server-side code (agents, actions,
@@ -905,14 +910,21 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
905
910
  if (event.event === MJEventType.ComponentEvent && event.eventCode === BaseEntity.BaseEventCode) {
906
911
  const beEvent = event.args as BaseEntityEvent;
907
912
  if (beEvent.type === 'save' || beEvent.type === 'delete') {
913
+ const entityName = beEvent.baseEntity.EntityInfo.Name;
908
914
  PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
909
- entityName: beEvent.baseEntity.EntityInfo.Name,
915
+ entityName,
910
916
  primaryKeyValues: JSON.stringify(beEvent.baseEntity.PrimaryKey.KeyValuePairs),
911
917
  action: beEvent.type,
912
918
  sourceServerId: MJGlobal.Instance.ProcessUUID,
913
919
  timestamp: new Date(),
914
920
  originSessionId: null,
915
- recordData: beEvent.type === 'save' ? JSON.stringify(beEvent.baseEntity.GetAll()) : undefined,
921
+ // Opt-in only: this event reaches every connected client unfiltered, and this listener
922
+ // fires for server-internal saves too (agents, actions, orchestrator), which are exactly
923
+ // the ones no browser session asked for.
924
+ recordData:
925
+ beEvent.type === 'save' && MayBroadcastRecordData(entityName)
926
+ ? JSON.stringify(beEvent.baseEntity.GetAll())
927
+ : undefined,
916
928
  });
917
929
  }
918
930
  }