@memberjunction/server 6.1.3 → 6.1.5

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 (93) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  4. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkService.js +49 -35
  6. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  7. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  8. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  9. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  10. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  11. package/dist/config.d.ts +53 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +23 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/context.d.ts.map +1 -1
  16. package/dist/context.js +11 -23
  17. package/dist/context.js.map +1 -1
  18. package/dist/generic/CacheInvalidationResolver.d.ts +87 -3
  19. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  20. package/dist/generic/CacheInvalidationResolver.js +118 -3
  21. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  22. package/dist/generic/DeleteOptionsInput.d.ts +8 -1
  23. package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
  24. package/dist/generic/DeleteOptionsInput.js +1 -1
  25. package/dist/generic/DeleteOptionsInput.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +4 -0
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +10 -3
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +49 -47
  32. package/dist/index.js.map +1 -1
  33. package/dist/postgresqlCompat.d.ts +8 -0
  34. package/dist/postgresqlCompat.d.ts.map +1 -0
  35. package/dist/postgresqlCompat.js +19 -0
  36. package/dist/postgresqlCompat.js.map +1 -0
  37. package/dist/postgresqlPoolSettings.d.ts +108 -0
  38. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  39. package/dist/postgresqlPoolSettings.js +134 -0
  40. package/dist/postgresqlPoolSettings.js.map +1 -0
  41. package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
  42. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  43. package/dist/resolvers/AdhocQueryResolver.js +54 -174
  44. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  45. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  46. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  48. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  49. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  50. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  51. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  52. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  53. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  54. package/dist/resolvers/adhocTimeout.js +12 -0
  55. package/dist/resolvers/adhocTimeout.js.map +1 -0
  56. package/dist/services/TaskGraphActionRunner.d.ts +13 -8
  57. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  58. package/dist/services/TaskGraphActionRunner.js +16 -15
  59. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  60. package/package.json +92 -92
  61. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
  62. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  63. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  64. package/src/__tests__/DeleteOptionsInput.wireShape.test.ts +62 -0
  65. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  66. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
  67. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  68. package/src/__tests__/cacheInvalidationRecordData.test.ts +177 -0
  69. package/src/__tests__/magicLink.test.ts +15 -76
  70. package/src/__tests__/magicLinkService.consume.test.ts +224 -0
  71. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  72. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  73. package/src/auth/magicLink/MagicLinkService.ts +55 -36
  74. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  75. package/src/config.ts +23 -0
  76. package/src/context.ts +11 -24
  77. package/src/generic/CacheInvalidationResolver.ts +139 -3
  78. package/src/generic/DeleteOptionsInput.ts +9 -2
  79. package/src/generic/ResolverBase.ts +11 -3
  80. package/src/index.ts +55 -48
  81. package/src/postgresqlCompat.ts +21 -0
  82. package/src/postgresqlPoolSettings.ts +213 -0
  83. package/src/resolvers/AdhocQueryResolver.ts +64 -194
  84. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  85. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  86. package/src/resolvers/adhocTimeout.ts +12 -0
  87. package/src/services/TaskGraphActionRunner.ts +16 -15
  88. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  89. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  90. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  91. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  92. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  93. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -106,75 +106,38 @@ export function evaluateInvite(invite: InviteEvaluationInput, nowMs: number): {
106
106
  return { ok: true };
107
107
  }
108
108
 
109
- /** SQL dialect for {@link buildConsumeInviteSQL}. */
110
- export type ConsumeInviteDialect = 'sqlserver' | 'postgresql';
111
-
112
- /** SQL Server table identifier — bracket-quoted `[schema].[table]` (word chars only). */
113
- const QUALIFIED_TABLE_PATTERN = /^\[\w+\]\.\[\w+\]$/;
114
109
  /** PostgreSQL table identifier — `schema.table` (word chars only); auto-quoted downstream. */
115
110
  const QUALIFIED_TABLE_PATTERN_PG = /^\w+\.\w+$/;
116
111
 
117
112
  /**
118
- * Builds the atomic compare-and-swap UPDATE that consumes one use of an invite,
119
- * in the given SQL dialect.
120
- *
121
- * The WHERE clause re-checks every eligibility condition at the DB level, so the
122
- * increment and the guard are a single atomic operation: concurrent redemptions
123
- * of a single-use link race on the row and exactly one matches. This is what
124
- * actually enforces single-use — the JS-side `evaluateInvite` is only a friendly
125
- * pre-check. The matched row's ID is returned to the caller (via `OUTPUT` on SQL
126
- * Server, `RETURNING` on PostgreSQL) so it can detect a win (exactly one row).
127
- *
128
- * The invite ID MUST be bound as a parameter by the caller (`@p0` on SQL Server,
129
- * `$1` on PostgreSQL) — it is never interpolated into the string, so this builder
130
- * is injection-safe regardless of the ID's contents.
113
+ * Builds the PostgreSQL atomic compare-and-swap UPDATE that consumes one use of an invite.
131
114
  *
132
- * **SQL Server** — `OUTPUT` goes `INTO` a table variable (not a bare OUTPUT):
133
- * SQL Server forbids a bare OUTPUT clause on a table that has enabled triggers,
134
- * and CodeGen adds an `__mj_UpdatedAt` trigger to every MJ table. The trailing
135
- * SELECT returns the matched row(s) regardless of trigger behavior.
115
+ * PostgreSQL only. SQL Server consumes through the granted `spConsumeMagicLinkInvite` procedure,
116
+ * because its runtime roles are never granted table DML (#4753). PostgreSQL needs neither: its
117
+ * runtime roles hold INSERT/UPDATE/DELETE on every `__mj` table
118
+ * (V202605040300__v5.33.x__Unblock_PostgreSQL_End_To_End.pg-only.sql), and no PG counterpart of
119
+ * the procedure exists — the release-time converter leaves `CREATE PROCEDURE` unhandled.
136
120
  *
137
- * **PostgreSQL** — the `UPDATE … WHERE` guard IS itself the atomic single-use
138
- * gate: the row is locked for the UPDATE's duration, so concurrent redemptions
139
- * serialize and only the first matching `Status='Active' AND UseCount < MaxUses`
140
- * wins; `RETURNING` yields the row iff the guard matched (equivalent to the SS
141
- * `OUTPUT`-into-table win-detect). PascalCase identifiers are auto-quoted by
142
- * `PostgreSQLDataProvider.ExecuteSQL`, so the table is passed unquoted (`schema.table`).
121
+ * The WHERE re-checks every eligibility condition (the same predicate as the procedure), so the
122
+ * increment and the guard are one atomic statement: concurrent redemptions of a single-use link
123
+ * serialize on the row lock and exactly one matches. `RETURNING ID` yields the row iff the guard
124
+ * matched. The invite ID MUST be bound by the caller as `$1` — it is never interpolated.
143
125
  *
144
- * `qualifiedTable` is asserted to match the dialect's whitelist pattern before
145
- * interpolation. The caller derives it from `EntityInfo` (never user input), so
146
- * this is defense-in-depth: even a future careless caller cannot turn this into
147
- * an injection vector — a non-conforming table throws.
148
- *
149
- * @param qualifiedTable `[schema].[table]` (sqlserver) or `schema.table` (postgresql) for MagicLinkInvite
150
- * @param dialect target SQL dialect (defaults to `'sqlserver'`)
126
+ * `qualifiedTable` is `schema.table` unquoted (PostgreSQLDataProvider.ExecuteSQL auto-quotes the
127
+ * PascalCase identifiers) and is asserted against a whitelist before interpolation — the caller
128
+ * never passes user input, so this is defense-in-depth.
151
129
  */
152
- export function buildConsumeInviteSQL(qualifiedTable: string, dialect: ConsumeInviteDialect = 'sqlserver'): string {
153
- if (dialect === 'postgresql') {
154
- if (!QUALIFIED_TABLE_PATTERN_PG.test(qualifiedTable)) {
155
- throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
156
- }
157
- return (
158
- `UPDATE ${qualifiedTable} ` +
159
- `SET UseCount = UseCount + 1, ` +
160
- `ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
161
- `Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
162
- `WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
163
- `RETURNING ID;`
164
- );
165
- }
166
- if (!QUALIFIED_TABLE_PATTERN.test(qualifiedTable)) {
167
- throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
130
+ export function buildConsumeInvitePostgresSQL(qualifiedTable: string): string {
131
+ if (!QUALIFIED_TABLE_PATTERN_PG.test(qualifiedTable)) {
132
+ throw new Error(`buildConsumeInvitePostgresSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
168
133
  }
169
134
  return (
170
- `DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER); ` +
171
135
  `UPDATE ${qualifiedTable} ` +
172
136
  `SET UseCount = UseCount + 1, ` +
173
- `ConsumedAt = COALESCE(ConsumedAt, SYSUTCDATETIME()), ` +
137
+ `ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
174
138
  `Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
175
- `OUTPUT INSERTED.ID INTO @consumed ` +
176
- `WHERE ID = @p0 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > SYSUTCDATETIME(); ` +
177
- `SELECT ID FROM @consumed;`
139
+ `WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
140
+ `RETURNING ID;`
178
141
  );
179
142
  }
180
143
 
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({
package/src/context.ts CHANGED
@@ -10,7 +10,8 @@ import { CloneUserForSessionContext } from './auth/sessionUserClone.js';
10
10
  import { GetAPIKeyActingContextResolver } from './auth/actingContextResolver.js';
11
11
  import { TokenExpiredError, AuthProviderFactory } from '@memberjunction/auth-providers';
12
12
  import { authCache } from './cache.js';
13
- import { userEmailMap, apiKey, mj_core_schema } from './config.js';
13
+ import { userEmailMap, apiKey, mj_core_schema, configInfo } from './config.js';
14
+ import { BuildPostgreSQLConnectionConfig, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials } from './postgresqlPoolSettings.js';
14
15
  import { buildBoundaryLogPayload } from './logging/boundaryLogPayload.js';
15
16
  import { StartupLogger } from './logging/StartupLogger.js';
16
17
  import { DataSourceInfo, UserPayload } from './types.js';
@@ -818,15 +819,10 @@ async function createPerRequestProviders(
818
819
  */
819
820
  async function createPostgresProvider(): Promise<DatabaseProviderBase> {
820
821
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
821
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
822
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
823
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
824
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
825
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
826
822
 
827
823
  const pgProvider = new PostgreSQLDataProvider();
828
824
  const pgConfig = new PostgreSQLProviderConfigData(
829
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgUser, Password: pgPass },
825
+ BuildPostgreSQLConnectionConfig(ResolvePostgreSQLEndpoint(), configInfo.databaseSettings, 'api'),
830
826
  mj_core_schema,
831
827
  0,
832
828
  undefined,
@@ -846,39 +842,30 @@ async function createPostgresProvider(): Promise<DatabaseProviderBase> {
846
842
  }
847
843
 
848
844
  /**
849
- * Attempts to create a read-only PostgreSQL provider using DB_READ_ONLY_USERNAME/PASSWORD.
850
- * Shares the connection pool from the primary provider. Returns null if no read-only credentials are configured.
845
+ * Creates a read-only PostgreSQL per-request provider on the read-only pool MJAPI opened at
846
+ * startup with the read-only login. Returns null when no read-only login is configured, so the
847
+ * caller never falls back to the read-write pool.
851
848
  */
852
849
  async function tryCreateReadOnlyPostgresProvider(): Promise<DatabaseProviderBase | null> {
853
- const roUser = process.env.PG_READ_ONLY_USERNAME || process.env.DB_READ_ONLY_USERNAME;
854
- const roPass = process.env.PG_READ_ONLY_PASSWORD || process.env.DB_READ_ONLY_PASSWORD;
855
- if (!roUser || !roPass) {
850
+ const readOnlyPool = PostgreSQLReadOnlyPool.Instance.Pool;
851
+ const credentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
852
+ if (!readOnlyPool || !credentials) {
856
853
  return null;
857
854
  }
858
855
 
859
856
  try {
860
857
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
861
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
862
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
863
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
864
858
 
865
859
  const roProvider = new PostgreSQLDataProvider();
866
860
  const roConfig = new PostgreSQLProviderConfigData(
867
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: roUser, Password: roPass },
861
+ BuildPostgreSQLConnectionConfig({ ...ResolvePostgreSQLEndpoint(), ...credentials }, configInfo.databaseSettings, 'read-only'),
868
862
  mj_core_schema,
869
863
  0,
870
864
  undefined,
871
865
  undefined,
872
866
  false,
873
867
  );
874
-
875
- const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool }; // global-provider-ok: bootstrap (share primary provider's PG pool with the read-only provider)
876
- if (primaryProvider?.DatabaseConnection) {
877
- await roProvider.ConfigWithSharedPool(roConfig, primaryProvider.DatabaseConnection);
878
- } else {
879
- await roProvider.Config(roConfig);
880
- }
881
-
868
+ await roProvider.ConfigWithSharedPool(roConfig, readOnlyPool);
882
869
  return roProvider;
883
870
  } catch (_err) {
884
871
  return null;
@@ -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
@@ -31,6 +31,8 @@ import { WebSocketServer } from 'ws';
31
31
  import { RealtimeProxyServer } from './realtimeProxy/RealtimeProxyServer.js';
32
32
  import buildApolloServer from './apolloServer/index.js';
33
33
  import { configInfo, configFilePath, dbDatabase, dbHost, dbPort, dbUsername, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage, RESTApiOptions } from './config.js';
34
+ import { TranslateBracketsToPG } from './postgresqlCompat.js';
35
+ import { BuildPostgreSQLConnectionConfig, DescribeReadOnlyLoginOverreach, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials, ToPGPoolConfig } from './postgresqlPoolSettings.js';
34
36
  import { default as jwt } from 'jsonwebtoken';
35
37
  import { contextFunction, createUnifiedAuthMiddleware, getUserPayload } from './context.js';
36
38
  import { UserPayload } from './types.js';
@@ -67,7 +69,7 @@ import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
67
69
  import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
68
70
  import { SessionJanitor } from './agentSessions/index.js';
69
71
  import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
70
- import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
72
+ import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData, ConfigureRecordDataBroadcast } from './generic/CacheInvalidationResolver.js';
71
73
  import { ConnectorFactory, IntegrationEngine, IntegrationSyncOptions } from '@memberjunction/integration-engine';
72
74
  import { CronExpressionHelper } from '@memberjunction/scheduling-engine';
73
75
  import {
@@ -316,25 +318,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
316
318
  startupLog.BeginPhase('Connecting to database');
317
319
  startupLog.LogIf('verbose', 'Database type: PostgreSQL');
318
320
  const pg = await import('pg');
319
- const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
321
+ const { PostgreSQLDataProvider, PostgreSQLProviderConfigData, MJPostgresTypes } = await import('@memberjunction/postgresql-dataprovider');
320
322
 
321
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
322
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
323
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
324
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
325
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
326
-
327
- const pgPool = new pg.default.Pool({
328
- host: pgHost,
329
- port: pgPort,
330
- user: pgUser,
331
- password: pgPass,
332
- database: pgDatabase,
333
- max: configInfo.databaseSettings.connectionPool?.max ?? 50,
334
- min: configInfo.databaseSettings.connectionPool?.min ?? 5,
335
- idleTimeoutMillis: configInfo.databaseSettings.connectionPool?.idleTimeoutMillis ?? 30000,
336
- connectionTimeoutMillis: configInfo.databaseSettings.connectionPool?.acquireTimeoutMillis ?? 30000,
337
- });
323
+ const pgEndpoint = ResolvePostgreSQLEndpoint();
324
+ const { Host: pgHost, Port: pgPort, User: pgUser, Database: pgDatabase } = pgEndpoint;
325
+ // Every API pool carries the statement and idle-in-transaction timeouts from connection #1.
326
+ const pgConnectionConfig = BuildPostgreSQLConnectionConfig(pgEndpoint, configInfo.databaseSettings, 'api');
327
+ const pgPool = new pg.default.Pool(ToPGPoolConfig(pgConnectionConfig));
338
328
 
339
329
  // Verify connection
340
330
  const testClient = await pgPool.connect();
@@ -342,6 +332,28 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
342
332
  testClient.release();
343
333
  startupLog.LogIf('verbose', `PostgreSQL pool connected to ${pgHost}:${pgPort}/${pgDatabase}`);
344
334
 
335
+ // A read-only pool opened with the read-only login, as SQL Server has. Read-only per-request
336
+ // providers (TestQuerySQL and other caller-supplied SQL) share it instead of the primary pool.
337
+ const pgReadOnlyCredentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
338
+ if (pgReadOnlyCredentials) {
339
+ // A pool a provider runs on must carry the provider's type parsers, so BIGINT and NUMERIC
340
+ // come back as numbers here as they do on the provider's own pool.
341
+ const readOnlyPgPool = new pg.default.Pool({
342
+ ...ToPGPoolConfig(BuildPostgreSQLConnectionConfig({ ...pgEndpoint, ...pgReadOnlyCredentials }, configInfo.databaseSettings, 'read-only')),
343
+ types: MJPostgresTypes,
344
+ });
345
+ const readOnlyTestClient = await readOnlyPgPool.connect();
346
+ try {
347
+ for (const warning of await DescribeReadOnlyLoginOverreach(readOnlyTestClient, mj_core_schema)) {
348
+ LogStatus(`WARNING: ${warning}`);
349
+ }
350
+ } finally {
351
+ readOnlyTestClient.release();
352
+ }
353
+ PostgreSQLReadOnlyPool.Instance.Pool = readOnlyPgPool;
354
+ startupLog.LogIf('verbose', 'Read-only PostgreSQL pool has been initialized.');
355
+ }
356
+
345
357
  // Create a DataSourceInfo with a MSSQL-compatible wrapper around pg.Pool
346
358
  // This allows existing code (types, util, context) to work without changes
347
359
  const mssqlCompatPool = createMSSQLCompatPool(pgPool);
@@ -355,15 +367,6 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
355
367
  }));
356
368
 
357
369
  // Set up the PostgreSQL provider
358
- const pgConnectionConfig = {
359
- Host: pgHost,
360
- Port: pgPort,
361
- Database: pgDatabase,
362
- User: pgUser,
363
- Password: pgPass,
364
- MaxConnections: configInfo.databaseSettings.connectionPool?.max ?? 50,
365
- MinConnections: configInfo.databaseSettings.connectionPool?.min ?? 5,
366
- };
367
370
  const pgConfigData = new PostgreSQLProviderConfigData(
368
371
  pgConnectionConfig,
369
372
  mj_core_schema,
@@ -397,7 +400,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
397
400
  if (poolAny._pgPool) {
398
401
  const thePgPool = poolAny._pgPool as import('pg').Pool;
399
402
  // Translate SQL Server bracket syntax to PostgreSQL double-quote syntax
400
- const pgQuery = translateBracketsToPG(query);
403
+ const pgQuery = TranslateBracketsToPG(query);
401
404
  const result = await thePgPool.query(pgQuery);
402
405
  return result.rows;
403
406
  }
@@ -416,21 +419,21 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
416
419
  const pgCodegenPass = process.env.CODEGEN_DB_PASSWORD;
417
420
  if (pgCodegenUser && pgCodegenPass) {
418
421
  try {
419
- const codegenPgPool = new pg.default.Pool({
420
- host: pgHost,
421
- port: pgPort,
422
- user: pgCodegenUser,
423
- password: pgCodegenPass,
424
- database: pgDatabase,
425
- max: 10,
426
- });
422
+ // CodeGen and DDL work runs long statements across every entity, so this pool gets the
423
+ // long CodeGen timeout rather than the API one, as the SQL Server CodeGen pool does.
424
+ const codegenPgConnectionConfig = BuildPostgreSQLConnectionConfig(
425
+ { ...pgEndpoint, User: pgCodegenUser, Password: pgCodegenPass },
426
+ configInfo.databaseSettings,
427
+ 'codegen',
428
+ );
429
+ const codegenPgPool = new pg.default.Pool(ToPGPoolConfig(codegenPgConnectionConfig));
427
430
  const codegenTestClient = await codegenPgPool.connect();
428
431
  await codegenTestClient.query('SELECT 1');
429
432
  codegenTestClient.release();
430
433
 
431
434
  const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
432
435
  const codegenPgConfigData = new PostgreSQLProviderConfigData(
433
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgCodegenUser, Password: pgCodegenPass },
436
+ codegenPgConnectionConfig,
434
437
  mj_core_schema,
435
438
  cacheRefreshInterval / 1000, // ms → seconds
436
439
  );
@@ -896,6 +899,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
896
899
  // publish hook above so the first RSU event also reaches live subscribers.
897
900
  RegisterRSUProgressBridge();
898
901
 
902
+ // Hand the resolver its allowlist before anything can publish. It cannot read configInfo itself:
903
+ // config.ts loads and validates at module scope, so importing it there would pull full config
904
+ // validation into every import chain that touches the resolver, unit tests included.
905
+ ConfigureRecordDataBroadcast(configInfo.cacheSettings?.recordDataBroadcastEntities);
906
+
899
907
  // Global listener: broadcast CACHE_INVALIDATION to all browser clients whenever
900
908
  // ANY BaseEntity save/delete occurs on this server — regardless of whether it
901
909
  // originated from a GraphQL mutation or internal server-side code (agents, actions,
@@ -905,14 +913,21 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
905
913
  if (event.event === MJEventType.ComponentEvent && event.eventCode === BaseEntity.BaseEventCode) {
906
914
  const beEvent = event.args as BaseEntityEvent;
907
915
  if (beEvent.type === 'save' || beEvent.type === 'delete') {
916
+ const entityName = beEvent.baseEntity.EntityInfo.Name;
908
917
  PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
909
- entityName: beEvent.baseEntity.EntityInfo.Name,
918
+ entityName,
910
919
  primaryKeyValues: JSON.stringify(beEvent.baseEntity.PrimaryKey.KeyValuePairs),
911
920
  action: beEvent.type,
912
921
  sourceServerId: MJGlobal.Instance.ProcessUUID,
913
922
  timestamp: new Date(),
914
923
  originSessionId: null,
915
- recordData: beEvent.type === 'save' ? JSON.stringify(beEvent.baseEntity.GetAll()) : undefined,
924
+ // Opt-in only: this event reaches every connected client unfiltered, and this listener
925
+ // fires for server-internal saves too (agents, actions, orchestrator), which are exactly
926
+ // the ones no browser session asked for.
927
+ recordData:
928
+ beEvent.type === 'save' && MayBroadcastRecordData(entityName)
929
+ ? JSON.stringify(beEvent.baseEntity.GetAll())
930
+ : undefined,
916
931
  });
917
932
  }
918
933
  }
@@ -2067,11 +2082,3 @@ function createMSSQLCompatPool(pgPool: import('pg').Pool): sql.ConnectionPool {
2067
2082
  return wrapper as unknown as sql.ConnectionPool;
2068
2083
  }
2069
2084
 
2070
- /**
2071
- * Translates SQL Server bracket-quoted identifiers to PostgreSQL double-quoted identifiers.
2072
- * Converts [schema].[table] to "schema"."table" and handles common T-SQL patterns.
2073
- */
2074
- function translateBracketsToPG(sql: string): string {
2075
- // Replace [identifier] with "identifier"
2076
- return sql.replace(/\[([^\]]+)\]/g, '"$1"');
2077
- }
@@ -0,0 +1,21 @@
1
+ import { LexSQL } from '@memberjunction/sql-parser';
2
+ import { SQLServerDialect } from '@memberjunction/sql-dialect';
3
+
4
+ const sqlServer = new SQLServerDialect();
5
+
6
+ /**
7
+ * Translates SQL Server bracket-quoted identifiers (`[Name]`) to PostgreSQL double-quoted ones
8
+ * (`"Name"`), for SQL written in SQL Server syntax that runs on PostgreSQL. Only identifiers
9
+ * change: brackets inside string literals and comments are left as written. A `]]` in a name
10
+ * becomes `]`, and a `"` in a name is doubled.
11
+ */
12
+ export function TranslateBracketsToPG(sql: string): string {
13
+ return LexSQL(sql, sqlServer)
14
+ .map(t => t.Kind === 'identifier' && t.Text.startsWith('[') ? toDoubleQuoted(t.Text) : t.Text)
15
+ .join('');
16
+ }
17
+
18
+ function toDoubleQuoted(bracketed: string): string {
19
+ const name = bracketed.slice(1, bracketed.endsWith(']') ? -1 : undefined).replace(/]]/g, ']');
20
+ return `"${name.replace(/"/g, '""')}"`;
21
+ }