@memberjunction/server 5.51.2 → 5.51.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.
Files changed (211) hide show
  1. package/README.md +34 -1
  2. package/dist/agentSessions/SessionManager.d.ts +5 -2
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +17 -8
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  10. package/dist/agentSessions/index.d.ts +1 -0
  11. package/dist/agentSessions/index.d.ts.map +1 -1
  12. package/dist/agentSessions/index.js +1 -0
  13. package/dist/agentSessions/index.js.map +1 -1
  14. package/dist/auth/actionAuthorization.d.ts +40 -0
  15. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  16. package/dist/auth/actionAuthorization.js +94 -0
  17. package/dist/auth/actionAuthorization.js.map +1 -0
  18. package/dist/auth/index.d.ts +1 -0
  19. package/dist/auth/index.d.ts.map +1 -1
  20. package/dist/auth/index.js +1 -0
  21. package/dist/auth/index.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +53 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +34 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts +6 -0
  31. package/dist/context.d.ts.map +1 -1
  32. package/dist/context.js +24 -34
  33. package/dist/context.js.map +1 -1
  34. package/dist/generated/generated.d.ts.map +1 -1
  35. package/dist/generated/generated.js +3262 -3273
  36. package/dist/generated/generated.js.map +1 -1
  37. package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
  38. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.js +170 -15
  40. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  41. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  42. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  43. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  44. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.d.ts +58 -0
  46. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  47. package/dist/generic/PushStatusResolver.js +42 -3
  48. package/dist/generic/PushStatusResolver.js.map +1 -1
  49. package/dist/generic/ResolverBase.d.ts +148 -3
  50. package/dist/generic/ResolverBase.d.ts.map +1 -1
  51. package/dist/generic/ResolverBase.js +287 -30
  52. package/dist/generic/ResolverBase.js.map +1 -1
  53. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  54. package/dist/generic/RunViewResolver.js +12 -0
  55. package/dist/generic/RunViewResolver.js.map +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +58 -50
  58. package/dist/index.js.map +1 -1
  59. package/dist/multiTenancy/index.d.ts.map +1 -1
  60. package/dist/multiTenancy/index.js +22 -2
  61. package/dist/multiTenancy/index.js.map +1 -1
  62. package/dist/postgresqlCompat.d.ts +8 -0
  63. package/dist/postgresqlCompat.d.ts.map +1 -0
  64. package/dist/postgresqlCompat.js +19 -0
  65. package/dist/postgresqlCompat.js.map +1 -0
  66. package/dist/postgresqlPoolSettings.d.ts +108 -0
  67. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  68. package/dist/postgresqlPoolSettings.js +134 -0
  69. package/dist/postgresqlPoolSettings.js.map +1 -0
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
  71. package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
  72. package/dist/resolvers/ActionResolver.d.ts +31 -5
  73. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  74. package/dist/resolvers/ActionResolver.js +75 -14
  75. package/dist/resolvers/ActionResolver.js.map +1 -1
  76. package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
  77. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  78. package/dist/resolvers/AdhocQueryResolver.js +97 -157
  79. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  80. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  81. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  82. package/dist/resolvers/DatasetResolver.js +57 -8
  83. package/dist/resolvers/DatasetResolver.js.map +1 -1
  84. package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  86. package/dist/resolvers/EntityRecordNameResolver.js +18 -6
  87. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  88. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  89. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  90. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  91. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  92. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataResolver.js +13 -1
  94. package/dist/resolvers/GetDataResolver.js.map +1 -1
  95. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  96. package/dist/resolvers/MCPResolver.js +13 -16
  97. package/dist/resolvers/MCPResolver.js.map +1 -1
  98. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
  99. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  100. package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
  101. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  102. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  103. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  104. package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
  105. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  106. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  108. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  109. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunTemplateResolver.js +12 -11
  111. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  112. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTestResolver.js +90 -112
  114. package/dist/resolvers/RunTestResolver.js.map +1 -1
  115. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  116. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  117. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  118. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  120. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  121. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  122. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  123. package/dist/resolvers/adhocTimeout.js +12 -0
  124. package/dist/resolvers/adhocTimeout.js.map +1 -0
  125. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  126. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  127. package/dist/rest/ViewOperationsHandler.js +29 -17
  128. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  129. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  130. package/dist/services/TaskOrchestrator.js +17 -15
  131. package/dist/services/TaskOrchestrator.js.map +1 -1
  132. package/package.json +90 -89
  133. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  134. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  135. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  136. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  137. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  138. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  139. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  140. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  141. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
  142. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
  143. package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
  144. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
  145. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
  146. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
  147. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  148. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
  149. package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
  150. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  151. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
  152. package/src/__tests__/SessionManager.test.ts +88 -13
  153. package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
  154. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  155. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
  156. package/src/__tests__/actionAuthorization.test.ts +130 -0
  157. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  158. package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
  159. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
  160. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  161. package/src/__tests__/multiTenancy.test.ts +1 -1
  162. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  163. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  164. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  165. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  166. package/src/__tests__/resolverBase.rls.test.ts +2 -0
  167. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  168. package/src/agentSessions/SessionManager.ts +24 -25
  169. package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
  170. package/src/agentSessions/index.ts +1 -0
  171. package/src/auth/actionAuthorization.ts +119 -0
  172. package/src/auth/index.ts +1 -0
  173. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  174. package/src/config.ts +34 -1
  175. package/src/context.ts +25 -34
  176. package/src/generated/generated.ts +3262 -3273
  177. package/src/generic/CacheInvalidationResolver.ts +206 -15
  178. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  179. package/src/generic/PushStatusResolver.ts +79 -4
  180. package/src/generic/ResolverBase.ts +339 -31
  181. package/src/generic/RunViewResolver.ts +17 -0
  182. package/src/index.ts +65 -52
  183. package/src/multiTenancy/index.ts +24 -2
  184. package/src/postgresqlCompat.ts +21 -0
  185. package/src/postgresqlPoolSettings.ts +213 -0
  186. package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
  187. package/src/resolvers/ActionResolver.ts +85 -22
  188. package/src/resolvers/AdhocQueryResolver.ts +110 -177
  189. package/src/resolvers/DatasetResolver.ts +70 -10
  190. package/src/resolvers/EntityRecordNameResolver.ts +24 -7
  191. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  192. package/src/resolvers/GetDataResolver.ts +14 -1
  193. package/src/resolvers/MCPResolver.ts +13 -15
  194. package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
  195. package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
  196. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  197. package/src/resolvers/RunTemplateResolver.ts +13 -12
  198. package/src/resolvers/RunTestResolver.ts +90 -112
  199. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  200. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  201. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  202. package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
  203. package/src/resolvers/adhocTimeout.ts +12 -0
  204. package/src/rest/ViewOperationsHandler.ts +37 -18
  205. package/src/services/TaskOrchestrator.ts +17 -17
  206. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  207. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  208. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  209. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  210. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  211. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -1,4 +1,31 @@
1
1
  export declare const CACHE_INVALIDATION_TOPIC = "CACHE_INVALIDATION";
2
+ /**
3
+ * Supply the configured allowlist. Called once from the server entry point during bootstrap.
4
+ *
5
+ * INJECTED rather than read from `configInfo` here, deliberately. `config.ts` runs `loadConfig()` at
6
+ * module scope, so importing it from this module would drag full config validation into every
7
+ * import chain that touches the resolver — including unit tests, which have no server config and
8
+ * would fail at import with "Configuration validation failed". This module deliberately imports
9
+ * nothing from MJServer itself — only type-graphql and `@memberjunction/core` (for the entity
10
+ * permission check in the subscription filter), neither of which loads config.
11
+ */
12
+ export declare function ConfigureRecordDataBroadcast(entities: readonly string[] | undefined | null): void;
13
+ /**
14
+ * May the full row of `entityName` be broadcast alongside its cache-invalidation event?
15
+ *
16
+ * The subscription below is filtered only by ENTITY-level read permission, so anything included
17
+ * here is disclosed to every signed-in session that may read the entity at all — regardless of
18
+ * row-level security or any tenant scoping a consumer applies on the read path, neither of which
19
+ * this push traverses. The answer is therefore NO unless a deployment has explicitly opted the
20
+ * entity in via `cacheSettings.recordDataBroadcastEntities`.
21
+ *
22
+ * Callers that get `false` simply omit `recordData`; the event still carries the entity name and
23
+ * primary key, so clients evict and re-fetch through the normal access-controlled read path.
24
+ *
25
+ * Matching is exact (trimmed, case-insensitive) rather than substring: a loose match would let an
26
+ * allowlisted name drag in every entity whose name contains it.
27
+ */
28
+ export declare function MayBroadcastRecordData(entityName: string): boolean;
2
29
  export declare class CacheInvalidationNotification {
3
30
  EntityName: string;
4
31
  PrimaryKeyValues?: string;
@@ -21,12 +48,105 @@ export interface CacheInvalidationPayload {
21
48
  originSessionId?: string;
22
49
  recordData?: string;
23
50
  }
51
+ /**
52
+ * What the subscription filter needs off the WS connection.
53
+ *
54
+ * Declared structurally rather than importing MJServer's `UserPayload`, which reaches `types.ts`
55
+ * and through it `auth/index.js` and config. Keeping this module's imports minimal is not fussiness
56
+ * — importing `configInfo` here is what previously broke every unit test that touched the resolver,
57
+ * and the import-surface test in this package exists to keep it that way.
58
+ */
59
+ export interface CacheInvalidationFilterContext {
60
+ userPayload?: {
61
+ userRecord?: unknown;
62
+ };
63
+ }
64
+ /**
65
+ * Should this subscriber hear about this event at all?
66
+ *
67
+ * Entity-level narrowing, raised in review: an event for an entity a session has no read
68
+ * permission on tells that session the record exists, its stable key, and when it changed — and
69
+ * the session could never have read it anyway, so nothing is lost by withholding it. This is a
70
+ * monotonic reduction in disclosure that needs no tenancy model, which is why it is here and the
71
+ * row-level question is not.
72
+ *
73
+ * What it does NOT close: two sessions that both hold read permission on an entity still see each
74
+ * other's keys, because what separates them is row-level. That predicate is tracked separately —
75
+ * see the class docblock below.
76
+ *
77
+ * `GetUserPermisions` is an in-memory match of the entity's permission rows against the user's
78
+ * roles, so this stays cheap enough to run per subscriber per event. It is not free: it runs for
79
+ * every connected socket on every save, so keep it free of I/O.
80
+ */
81
+ export declare function cacheInvalidationFilter(data: {
82
+ payload: CacheInvalidationPayload;
83
+ context: CacheInvalidationFilterContext | undefined;
84
+ }): boolean;
85
+ /** The part of a permission row and of a user this module reads, so the rule can be tested on plain objects. */
86
+ export interface ReadPermissionRow {
87
+ RoleID: string;
88
+ CanRead: boolean | null;
89
+ /** `Deny` rows subtract; they never carry the read the rule looks at. */
90
+ IsDeny?: boolean;
91
+ Type?: string | null;
92
+ ReadRLSFilterID: string | null;
93
+ }
94
+ export interface RoleHolder {
95
+ UserRoles?: ReadonlyArray<{
96
+ RoleID: string;
97
+ }> | null;
98
+ }
99
+ /**
100
+ * Is this user's read of the entity row-filtered? True when every read the user holds on the entity comes through a permission
101
+ * row that carries a `ReadRLSFilterID`: there is a row-level security filter between them and the rows, so a record's key, its
102
+ * existence and when it changed are not theirs to learn unless the row passes the filter — which a subscription filter cannot
103
+ * evaluate per event. False when any of their read rows is unfiltered (they could read the row anyway), and false when they hold
104
+ * no read at all (the entity-level filter has already withheld the event).
105
+ *
106
+ * The same in-memory match `GetUserPermisions` does, with no I/O, so it can run per subscriber per event.
107
+ */
108
+ export declare function ReadIsRowFiltered(entity: {
109
+ Permissions: ReadonlyArray<ReadPermissionRow>;
110
+ }, user: RoleHolder): boolean;
111
+ /**
112
+ * The event as one subscriber may see it. A subscriber whose read of the entity is row-filtered gets the entity, the action and
113
+ * the time, and neither the key nor the row: "something in this entity changed", which the client already takes as a
114
+ * whole-entity invalidation (a null key on `remote-invalidate`). Everyone else gets the event as published.
115
+ */
116
+ export declare function ShapeCacheInvalidationEvent(payload: CacheInvalidationPayload, user: RoleHolder | undefined, entity: {
117
+ Permissions: ReadonlyArray<ReadPermissionRow>;
118
+ } | null | undefined): CacheInvalidationNotification;
24
119
  export declare class CacheInvalidationResolver {
25
120
  /**
26
- * Subscription that broadcasts cache invalidation events to ALL connected clients.
27
- * No session filter — every browser connected via WebSocket receives every event.
28
- * This enables cross-server cache invalidation to propagate to browser clients.
121
+ * Subscription that broadcasts cache invalidation events to connected clients, so cross-server
122
+ * cache invalidation reaches browsers.
123
+ *
124
+ * Delivery is filtered only by ENTITY-level read permission (see {@link cacheInvalidationFilter}).
125
+ * Every session that may read an entity at all still receives every event for it, from every
126
+ * other session and tenant.
127
+ *
128
+ * Because that narrowing stops well short of row level, `RecordData` is only populated for entities a
129
+ * deployment has opted in through `cacheSettings.recordDataBroadcastEntities` — see
130
+ * {@link MayBroadcastRecordData}, applied at both publish sites.
131
+ *
132
+ * `EntityName`, `PrimaryKeyValues`, `Action` and `Timestamp` ARE still broadcast to every
133
+ * session, and that is a disclosure, not a safe residue. An earlier version of this comment
134
+ * called them "safe to broadcast" on the grounds that reading the record still goes through
135
+ * the access-controlled path — true of the row's CONTENTS, and beside the point for the
136
+ * metadata itself. A session with no relationship to a record still learns that it exists,
137
+ * its stable key, and every time it changes; across entities it learns the cadence and volume
138
+ * of another tenant's activity.
139
+ *
140
+ * The key is kept for a subscriber who could read the row anyway, because consumers need it to
141
+ * re-read the record they were told about (see `ResolveEntityEventKey` in @memberjunction/core),
142
+ * and the coarse "something in this entity changed" forces whole-entity invalidation on the
143
+ * hottest write paths. It is withheld, with the row, from a subscriber whose every read of the
144
+ * entity goes through a row-level security filter ({@link ReadIsRowFiltered}): a filter the
145
+ * subscription cannot evaluate per event stands between them and the row, so they learn only
146
+ * that a row of the entity changed, and their caches refresh the entity as a whole. That is the
147
+ * same in-memory role match as the entity-level filter, once more per subscriber per event.
29
148
  */
30
- cacheInvalidation(payload: CacheInvalidationPayload): CacheInvalidationNotification;
149
+ cacheInvalidation(// case-violation-ok-legacy-back-compat: the property name is the GraphQL schema field name — renaming it breaks every client query
150
+ payload: CacheInvalidationPayload, context: CacheInvalidationFilterContext | undefined): CacheInvalidationNotification;
31
151
  }
32
152
  //# sourceMappingURL=CacheInvalidationResolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"CacheInvalidationResolver.d.ts","sourceRoot":"","sources":["../../src/generic/CacheInvalidationResolver.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,wBAAwB,uBAAuB,CAAC;AAE7D,qBACa,6BAA6B;IAEtC,UAAU,EAAG,MAAM,CAAC;IAGpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAG1B,MAAM,EAAG,MAAM,CAAC;IAGhB,cAAc,EAAG,MAAM,CAAC;IAGxB,SAAS,EAAG,IAAI,CAAC;IAGjB,eAAe,CAAC,EAAE,MAAM,CAAC;IAGzB,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;GAGG;AACH,MAAM,WAAW,wBAAwB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,IAAI,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qBACa,yBAAyB;IAClC;;;;OAIG;IAIH,iBAAiB,CACL,OAAO,EAAE,wBAAwB,GAC1C,6BAA6B;CAWnC"}
1
+ {"version":3,"file":"CacheInvalidationResolver.d.ts","sourceRoot":"","sources":["../../src/generic/CacheInvalidationResolver.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,wBAAwB,uBAAuB,CAAC;AAS7D;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CASjG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAIlE;AAED,qBACa,6BAA6B;IAEtC,UAAU,EAAG,MAAM,CAAC;IAGpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAG1B,MAAM,EAAG,MAAM,CAAC;IAGhB,cAAc,EAAG,MAAM,CAAC;IAGxB,SAAS,EAAG,IAAI,CAAC;IAGjB,eAAe,CAAC,EAAE,MAAM,CAAC;IAGzB,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;GAGG;AACH,MAAM,WAAW,wBAAwB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,IAAI,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,8BAA8B;IAC3C,WAAW,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE;IAC1C,OAAO,EAAE,wBAAwB,CAAC;IAClC,OAAO,EAAE,8BAA8B,GAAG,SAAS,CAAC;CACvD,GAAG,OAAO,CA2BV;AAED,gHAAgH;AAChH,MAAM,WAAW,iBAAiB;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACxB,yEAAyE;IACzE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AACD,MAAM,WAAW,UAAU;IACvB,SAAS,CAAC,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,IAAI,CAAC;CACxD;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IAAE,WAAW,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAA;CAAE,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAYtH;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CACvC,OAAO,EAAE,wBAAwB,EACjC,IAAI,EAAE,UAAU,GAAG,SAAS,EAC5B,MAAM,EAAE;IAAE,WAAW,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC7E,6BAA6B,CAW/B;AAED,qBACa,yBAAyB;IAClC;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IAMH,iBAAiB,CAAG,mIAAmI;IAC3I,OAAO,EAAE,wBAAwB,EAClC,OAAO,EAAE,8BAA8B,GAAG,SAAS,GAC3D,6BAA6B;CAMnC"}
@@ -10,8 +10,57 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  var __param = (this && this.__param) || function (paramIndex, decorator) {
11
11
  return function (target, key) { decorator(target, key, paramIndex); }
12
12
  };
13
- import { Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
13
+ import { Ctx, Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
14
+ import { Metadata } from '@memberjunction/core';
14
15
  export const CACHE_INVALIDATION_TOPIC = 'CACHE_INVALIDATION';
16
+ /**
17
+ * Entities whose rows may ride along with an invalidation event. Empty until the server entry point
18
+ * supplies the configured list, so the default — including in any process that never configures it
19
+ * — is to broadcast nothing.
20
+ */
21
+ let allowedRecordDataEntities = [];
22
+ /**
23
+ * Supply the configured allowlist. Called once from the server entry point during bootstrap.
24
+ *
25
+ * INJECTED rather than read from `configInfo` here, deliberately. `config.ts` runs `loadConfig()` at
26
+ * module scope, so importing it from this module would drag full config validation into every
27
+ * import chain that touches the resolver — including unit tests, which have no server config and
28
+ * would fail at import with "Configuration validation failed". This module deliberately imports
29
+ * nothing from MJServer itself — only type-graphql and `@memberjunction/core` (for the entity
30
+ * permission check in the subscription filter), neither of which loads config.
31
+ */
32
+ export function ConfigureRecordDataBroadcast(entities) {
33
+ // Normalised HERE, once, rather than at each comparison. The wildcard used to be matched raw
34
+ // (`includes('*')`) while entity names were trimmed and lowercased two lines below, so a
35
+ // hand-edited `[' * ']` matched neither branch and silently opted nothing in — a config that
36
+ // looks like it disables the gate while leaving it fully on. Empty entries are dropped so a
37
+ // stray `''` cannot match an entity whose name is somehow blank.
38
+ allowedRecordDataEntities = (entities ?? [])
39
+ .map((e) => e.trim().toLowerCase())
40
+ .filter((e) => e.length > 0);
41
+ }
42
+ /**
43
+ * May the full row of `entityName` be broadcast alongside its cache-invalidation event?
44
+ *
45
+ * The subscription below is filtered only by ENTITY-level read permission, so anything included
46
+ * here is disclosed to every signed-in session that may read the entity at all — regardless of
47
+ * row-level security or any tenant scoping a consumer applies on the read path, neither of which
48
+ * this push traverses. The answer is therefore NO unless a deployment has explicitly opted the
49
+ * entity in via `cacheSettings.recordDataBroadcastEntities`.
50
+ *
51
+ * Callers that get `false` simply omit `recordData`; the event still carries the entity name and
52
+ * primary key, so clients evict and re-fetch through the normal access-controlled read path.
53
+ *
54
+ * Matching is exact (trimmed, case-insensitive) rather than substring: a loose match would let an
55
+ * allowlisted name drag in every entity whose name contains it.
56
+ */
57
+ export function MayBroadcastRecordData(entityName) {
58
+ if (allowedRecordDataEntities.length === 0)
59
+ return false;
60
+ if (allowedRecordDataEntities.includes('*'))
61
+ return true;
62
+ return allowedRecordDataEntities.includes(entityName.trim().toLowerCase());
63
+ }
15
64
  let CacheInvalidationNotification = class CacheInvalidationNotification {
16
65
  };
17
66
  __decorate([
@@ -46,31 +95,137 @@ CacheInvalidationNotification = __decorate([
46
95
  ObjectType()
47
96
  ], CacheInvalidationNotification);
48
97
  export { CacheInvalidationNotification };
98
+ /**
99
+ * Should this subscriber hear about this event at all?
100
+ *
101
+ * Entity-level narrowing, raised in review: an event for an entity a session has no read
102
+ * permission on tells that session the record exists, its stable key, and when it changed — and
103
+ * the session could never have read it anyway, so nothing is lost by withholding it. This is a
104
+ * monotonic reduction in disclosure that needs no tenancy model, which is why it is here and the
105
+ * row-level question is not.
106
+ *
107
+ * What it does NOT close: two sessions that both hold read permission on an entity still see each
108
+ * other's keys, because what separates them is row-level. That predicate is tracked separately —
109
+ * see the class docblock below.
110
+ *
111
+ * `GetUserPermisions` is an in-memory match of the entity's permission rows against the user's
112
+ * roles, so this stays cheap enough to run per subscriber per event. It is not free: it runs for
113
+ * every connected socket on every save, so keep it free of I/O.
114
+ */
115
+ export function cacheInvalidationFilter(data) {
116
+ const user = data.context?.userPayload?.userRecord;
117
+ // FAIL CLOSED on identity, matching `statusUpdatesFilter`. `onConnect` rejects a socket whose
118
+ // token does not validate, so a subscriber with no user record should not exist; if one does,
119
+ // it is the case least deserving of an unfiltered firehose.
120
+ if (!user) {
121
+ return false;
122
+ }
123
+ const entityName = data.payload?.entityName;
124
+ if (!entityName) {
125
+ return false;
126
+ }
127
+ // A pub/sub subscription filter has no per-request provider to thread: it runs on the server's
128
+ // own metadata, which is the one place the entity's permission rows can be answered from.
129
+ const entity = new Metadata().EntityByName(entityName); // global-provider-ok: subscription filter — server-wide metadata, no request-scoped provider exists here
130
+ // DELIVER when the entity is unknown here, deliberately — the opposite of the choice above.
131
+ // A name absent from metadata cannot be permission-checked, and withholding would silently
132
+ // stop invalidating a legitimately cacheable entity, which is a correctness bug rather than a
133
+ // disclosure one. All it discloses is the name and key of something this server cannot
134
+ // describe.
135
+ if (!entity) {
136
+ return true;
137
+ }
138
+ return entity.GetUserPermisions(user).CanRead;
139
+ }
140
+ /**
141
+ * Is this user's read of the entity row-filtered? True when every read the user holds on the entity comes through a permission
142
+ * row that carries a `ReadRLSFilterID`: there is a row-level security filter between them and the rows, so a record's key, its
143
+ * existence and when it changed are not theirs to learn unless the row passes the filter — which a subscription filter cannot
144
+ * evaluate per event. False when any of their read rows is unfiltered (they could read the row anyway), and false when they hold
145
+ * no read at all (the entity-level filter has already withheld the event).
146
+ *
147
+ * The same in-memory match `GetUserPermisions` does, with no I/O, so it can run per subscriber per event.
148
+ */
149
+ export function ReadIsRowFiltered(entity, user) {
150
+ const roles = new Set((user.UserRoles ?? []).map((role) => (role.RoleID ?? '').trim().toLowerCase()));
151
+ let filteredReads = 0;
152
+ for (const row of entity.Permissions) {
153
+ if (!row.CanRead)
154
+ continue;
155
+ const isDeny = row.IsDeny ?? (row.Type ?? 'Allow').trim().toLowerCase() === 'deny';
156
+ if (isDeny)
157
+ continue;
158
+ if (!roles.has((row.RoleID ?? '').trim().toLowerCase()))
159
+ continue;
160
+ if (!row.ReadRLSFilterID)
161
+ return false;
162
+ filteredReads += 1;
163
+ }
164
+ return filteredReads > 0;
165
+ }
166
+ /**
167
+ * The event as one subscriber may see it. A subscriber whose read of the entity is row-filtered gets the entity, the action and
168
+ * the time, and neither the key nor the row: "something in this entity changed", which the client already takes as a
169
+ * whole-entity invalidation (a null key on `remote-invalidate`). Everyone else gets the event as published.
170
+ */
171
+ export function ShapeCacheInvalidationEvent(payload, user, entity) {
172
+ const withholdKey = !!user && !!entity && ReadIsRowFiltered(entity, user);
173
+ return {
174
+ EntityName: payload.entityName,
175
+ PrimaryKeyValues: withholdKey ? undefined : (payload.primaryKeyValues ?? undefined),
176
+ Action: payload.action,
177
+ SourceServerID: payload.sourceServerId,
178
+ Timestamp: payload.timestamp,
179
+ OriginSessionID: payload.originSessionId ?? undefined,
180
+ RecordData: withholdKey ? undefined : (payload.recordData ?? undefined),
181
+ };
182
+ }
49
183
  let CacheInvalidationResolver = class CacheInvalidationResolver {
50
184
  /**
51
- * Subscription that broadcasts cache invalidation events to ALL connected clients.
52
- * No session filter — every browser connected via WebSocket receives every event.
53
- * This enables cross-server cache invalidation to propagate to browser clients.
185
+ * Subscription that broadcasts cache invalidation events to connected clients, so cross-server
186
+ * cache invalidation reaches browsers.
187
+ *
188
+ * Delivery is filtered only by ENTITY-level read permission (see {@link cacheInvalidationFilter}).
189
+ * Every session that may read an entity at all still receives every event for it, from every
190
+ * other session and tenant.
191
+ *
192
+ * Because that narrowing stops well short of row level, `RecordData` is only populated for entities a
193
+ * deployment has opted in through `cacheSettings.recordDataBroadcastEntities` — see
194
+ * {@link MayBroadcastRecordData}, applied at both publish sites.
195
+ *
196
+ * `EntityName`, `PrimaryKeyValues`, `Action` and `Timestamp` ARE still broadcast to every
197
+ * session, and that is a disclosure, not a safe residue. An earlier version of this comment
198
+ * called them "safe to broadcast" on the grounds that reading the record still goes through
199
+ * the access-controlled path — true of the row's CONTENTS, and beside the point for the
200
+ * metadata itself. A session with no relationship to a record still learns that it exists,
201
+ * its stable key, and every time it changes; across entities it learns the cadence and volume
202
+ * of another tenant's activity.
203
+ *
204
+ * The key is kept for a subscriber who could read the row anyway, because consumers need it to
205
+ * re-read the record they were told about (see `ResolveEntityEventKey` in @memberjunction/core),
206
+ * and the coarse "something in this entity changed" forces whole-entity invalidation on the
207
+ * hottest write paths. It is withheld, with the row, from a subscriber whose every read of the
208
+ * entity goes through a row-level security filter ({@link ReadIsRowFiltered}): a filter the
209
+ * subscription cannot evaluate per event stands between them and the row, so they learn only
210
+ * that a row of the entity changed, and their caches refresh the entity as a whole. That is the
211
+ * same in-memory role match as the entity-level filter, once more per subscriber per event.
54
212
  */
55
- cacheInvalidation(payload) {
56
- return {
57
- EntityName: payload.entityName,
58
- PrimaryKeyValues: payload.primaryKeyValues ?? undefined,
59
- Action: payload.action,
60
- SourceServerID: payload.sourceServerId,
61
- Timestamp: payload.timestamp,
62
- OriginSessionID: payload.originSessionId ?? undefined,
63
- RecordData: payload.recordData ?? undefined,
64
- };
213
+ cacheInvalidation(payload, context) {
214
+ const user = context?.userPayload?.userRecord;
215
+ // The same server-wide metadata the filter read; an entity unknown here was delivered whole by the filter's own choice
216
+ const entity = payload?.entityName ? new Metadata().EntityByName(payload.entityName) : null; // global-provider-ok: subscription resolver — server-wide metadata, no request-scoped provider exists here
217
+ return ShapeCacheInvalidationEvent(payload, user, entity);
65
218
  }
66
219
  };
67
220
  __decorate([
68
221
  Subscription(() => CacheInvalidationNotification, {
69
222
  topics: CACHE_INVALIDATION_TOPIC,
223
+ filter: (data) => cacheInvalidationFilter(data),
70
224
  }),
71
225
  __param(0, Root()),
226
+ __param(1, Ctx()),
72
227
  __metadata("design:type", Function),
73
- __metadata("design:paramtypes", [Object]),
228
+ __metadata("design:paramtypes", [Object, Object]),
74
229
  __metadata("design:returntype", CacheInvalidationNotification)
75
230
  ], CacheInvalidationResolver.prototype, "cacheInvalidation", null);
76
231
  CacheInvalidationResolver = __decorate([
@@ -1 +1 @@
1
- {"version":3,"file":"CacheInvalidationResolver.js","sourceRoot":"","sources":["../../src/generic/CacheInvalidationResolver.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE/E,MAAM,CAAC,MAAM,wBAAwB,GAAG,oBAAoB,CAAC;AAGtD,IAAM,6BAA6B,GAAnC,MAAM,6BAA6B;CAqBzC,CAAA;AAnBG;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC;;iEACA;AAGpB;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;uEACd;AAG1B;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC;;6DACJ;AAGhB;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC;;qEACI;AAGxB;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC;8BACN,IAAI;gEAAC;AAGjB;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;sEACf;AAGzB;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;iEACpB;AApBX,6BAA6B;IADzC,UAAU,EAAE;GACA,6BAA6B,CAqBzC;;AAiBM,IAAM,yBAAyB,GAA/B,MAAM,yBAAyB;IAClC;;;;OAIG;IAIH,iBAAiB,CACL,OAAiC;QAEzC,OAAO;YACH,UAAU,EAAE,OAAO,CAAC,UAAU;YAC9B,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,IAAI,SAAS;YACvD,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,cAAc,EAAE,OAAO,CAAC,cAAc;YACtC,SAAS,EAAE,OAAO,CAAC,SAAS;YAC5B,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,SAAS;YACrD,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,SAAS;SAC9C,CAAC;IACN,CAAC;CACJ,CAAA;AAbG;IAHC,YAAY,CAAC,GAAG,EAAE,CAAC,6BAA6B,EAAE;QAC/C,MAAM,EAAE,wBAAwB;KACnC,CAAC;IAEG,WAAA,IAAI,EAAE,CAAA;;;oCACR,6BAA6B;kEAU/B;AArBQ,yBAAyB;IADrC,QAAQ,EAAE;GACE,yBAAyB,CAsBrC"}
1
+ {"version":3,"file":"CacheInvalidationResolver.js","sourceRoot":"","sources":["../../src/generic/CacheInvalidationResolver.ts"],"names":[],"mappings":"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"}
@@ -25,6 +25,11 @@ export interface LivenessPulseOptions {
25
25
  pubSub: PubSubEngine;
26
26
  /** Session the client is subscribed on (used by the subscription filter). */
27
27
  sessionId: string;
28
+ /**
29
+ * Authenticated user the operation belongs to (B49). Stamped on every pulse so the
30
+ * subscription filter binds delivery to identity, not just the client-chosen sessionId.
31
+ */
32
+ ownerUserId: string;
28
33
  /** Resolver label echoed in the message envelope (e.g. 'RunAIAgentResolver'). */
29
34
  resolver: string;
30
35
  /** Pulse cadence in ms. Defaults to {@link DEFAULT_PULSE_INTERVAL_MS}. */
@@ -1 +1 @@
1
- {"version":3,"file":"FireAndForgetHeartbeat.d.ts","sourceRoot":"","sources":["../../src/generic/FireAndForgetHeartbeat.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAI5C,uEAAuE;AACvE,eAAO,MAAM,yBAAyB,QAAgB,CAAC;AAEvD,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,cAAc,CAAC;AAElD;;;;GAIG;AACH,MAAM,WAAW,WAAW;IACxB,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,0FAA0F;AAC1F,MAAM,WAAW,mBAAmB;IAChC,IAAI,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACjC,qEAAqE;IACrE,MAAM,EAAE,YAAY,CAAC;IACrB,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,WAAW,GAAG,SAAS,CAAC;CAC9C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,oBAAoB,GAAG,mBAAmB,CA2BrF"}
1
+ {"version":3,"file":"FireAndForgetHeartbeat.d.ts","sourceRoot":"","sources":["../../src/generic/FireAndForgetHeartbeat.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAI5C,uEAAuE;AACvE,eAAO,MAAM,yBAAyB,QAAgB,CAAC;AAEvD,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,cAAc,CAAC;AAElD;;;;GAIG;AACH,MAAM,WAAW,WAAW;IACxB,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,0FAA0F;AAC1F,MAAM,WAAW,mBAAmB;IAChC,IAAI,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACjC,qEAAqE;IACrE,MAAM,EAAE,YAAY,CAAC;IACrB,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,WAAW,GAAG,SAAS,CAAC;CAC9C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,oBAAoB,GAAG,mBAAmB,CA4BrF"}
@@ -1,5 +1,5 @@
1
1
  import { LogError } from '@memberjunction/core';
2
- import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
2
+ import { publishStatusUpdate } from './PushStatusResolver.js';
3
3
  /** Default cadence for fire-and-forget liveness pulses (5 minutes). */
4
4
  export const DEFAULT_PULSE_INTERVAL_MS = 5 * 60 * 1000;
5
5
  /** The `type` discriminator carried by liveness pulse messages. */
@@ -16,7 +16,7 @@ export const HEARTBEAT_MESSAGE_TYPE = 'Heartbeat';
16
16
  * so the client receives it through the same subscription with no special parsing.
17
17
  */
18
18
  export function startLivenessPulse(options) {
19
- const { pubSub, sessionId, resolver, readStatus } = options;
19
+ const { pubSub, sessionId, ownerUserId, resolver, readStatus } = options;
20
20
  const intervalMs = options.intervalMs ?? DEFAULT_PULSE_INTERVAL_MS;
21
21
  const timer = setInterval(() => {
22
22
  let data;
@@ -27,14 +27,15 @@ export function startLivenessPulse(options) {
27
27
  // A status read failure must not break the liveness loop.
28
28
  LogError(`[LivenessPulse:${resolver}] readStatus failed: ${e.message}`);
29
29
  }
30
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
30
+ publishStatusUpdate(pubSub, {
31
+ sessionId,
32
+ ownerUserId,
31
33
  message: JSON.stringify({
32
34
  resolver,
33
35
  type: HEARTBEAT_MESSAGE_TYPE,
34
36
  status: 'ok',
35
37
  data: data ?? {},
36
38
  }),
37
- sessionId,
38
39
  });
39
40
  }, intervalMs);
40
41
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"FireAndForgetHeartbeat.js","sourceRoot":"","sources":["../../src/generic/FireAndForgetHeartbeat.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAEpE,uEAAuE;AACvE,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAEvD,mEAAmE;AACnE,MAAM,CAAC,MAAM,sBAAsB,GAAG,WAAW,CAAC;AAsClD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAA6B;IAC5D,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAC5D,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,yBAAyB,CAAC;IAEnE,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;QAC3B,IAAI,IAA6B,CAAC;QAClC,IAAI,CAAC;YACD,IAAI,GAAG,UAAU,EAAE,EAAE,CAAC;QAC1B,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACT,0DAA0D;YAC1D,QAAQ,CAAC,kBAAkB,QAAQ,wBAAyB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QACvF,CAAC;QAED,MAAM,CAAC,OAAO,CAAC,yBAAyB,EAAE;YACtC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC;gBACpB,QAAQ;gBACR,IAAI,EAAE,sBAAsB;gBAC5B,MAAM,EAAE,IAAI;gBACZ,IAAI,EAAE,IAAI,IAAI,EAAE;aACnB,CAAC;YACF,SAAS;SACZ,CAAC,CAAC;IACP,CAAC,EAAE,UAAU,CAAC,CAAC;IAEf,OAAO;QACH,IAAI,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC;KACnC,CAAC;AACN,CAAC"}
1
+ {"version":3,"file":"FireAndForgetHeartbeat.js","sourceRoot":"","sources":["../../src/generic/FireAndForgetHeartbeat.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAE9D,uEAAuE;AACvE,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAEvD,mEAAmE;AACnE,MAAM,CAAC,MAAM,sBAAsB,GAAG,WAAW,CAAC;AA2ClD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAA6B;IAC5D,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IACzE,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,yBAAyB,CAAC;IAEnE,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;QAC3B,IAAI,IAA6B,CAAC;QAClC,IAAI,CAAC;YACD,IAAI,GAAG,UAAU,EAAE,EAAE,CAAC;QAC1B,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACT,0DAA0D;YAC1D,QAAQ,CAAC,kBAAkB,QAAQ,wBAAyB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QACvF,CAAC;QAED,mBAAmB,CAAC,MAAM,EAAE;YACxB,SAAS;YACT,WAAW;YACX,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC;gBACpB,QAAQ;gBACR,IAAI,EAAE,sBAAsB;gBAC5B,MAAM,EAAE,IAAI;gBACZ,IAAI,EAAE,IAAI,IAAI,EAAE;aACnB,CAAC;SACL,CAAC,CAAC;IACP,CAAC,EAAE,UAAU,CAAC,CAAC;IAEf,OAAO;QACH,IAAI,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC;KACnC,CAAC;AACN,CAAC"}
@@ -1,14 +1,72 @@
1
+ import { PubSubEngine } from 'type-graphql';
2
+ import { UserPayload } from '../types.js';
1
3
  export declare const PUSH_STATUS_UPDATES_TOPIC = "PUSH_STATUS_UPDATES";
2
4
  export declare class PushStatusNotification {
3
5
  message?: string;
4
6
  date: Date;
5
7
  sessionId: string;
6
8
  }
9
+ /**
10
+ * Internal wire payload for a push-status update. Carries `ownerUserId` — the authenticated user
11
+ * the update belongs to — used ONLY server-side by the subscription filter and deliberately NOT
12
+ * exposed on the client-facing {@link PushStatusNotification} (we never leak user IDs to subscribers).
13
+ *
14
+ * `ownerUserId` is REQUIRED: it is the load-bearing security property (see
15
+ * {@link statusUpdatesFilter}), so making it required means the compiler rejects any publish that
16
+ * omits identity. Always publish through {@link publishStatusUpdate} (or the
17
+ * `ResolverBase.PublishStatusUpdate` ergonomic wrapper) rather than calling `pubSub.publish` on
18
+ * this topic directly, so a new publisher can never forget it.
19
+ */
7
20
  export interface PushStatusNotificationPayload {
8
21
  message?: string;
9
22
  sessionId: string;
23
+ /** Authenticated user the update belongs to. Server-side filter key; never sent to the client. */
24
+ ownerUserId: string;
10
25
  }
26
+ interface PushStatusNotificationArgs {
27
+ sessionId: string;
28
+ }
29
+ /** Parameters for {@link publishStatusUpdate} — identity (`ownerUserId`) is required by design. */
30
+ export interface StatusUpdateParams {
31
+ /** Session the target client subscribed on (routes the push to the right browser tab). */
32
+ sessionId: string;
33
+ /** Authenticated user the operation belongs to (the trust anchor the filter enforces). */
34
+ ownerUserId: string;
35
+ /** Message envelope (plain string, or the resolvers' JSON `{resolver,type,status,data}` shape). */
36
+ message?: string;
37
+ }
38
+ /**
39
+ * The single, centralized vehicle for publishing on {@link PUSH_STATUS_UPDATES_TOPIC}. Every
40
+ * publisher — resolver or service — routes through here (resolvers via the
41
+ * `ResolverBase.PublishStatusUpdate` wrapper; non-resolvers by calling this directly), so the
42
+ * payload shape and the required-identity guarantee live in exactly one place. Adding a field to
43
+ * the push is a one-line change here; a new publisher physically cannot omit `ownerUserId`.
44
+ */
45
+ export declare function publishStatusUpdate(pubSub: PubSubEngine, params: StatusUpdateParams): void;
46
+ /** Minimal shape of the subscription's connection context needed by the filter. */
47
+ export interface StatusUpdatesFilterContext {
48
+ userPayload?: UserPayload;
49
+ }
50
+ /**
51
+ * Filter predicate for the `statusUpdates` subscription. Exported so it can be unit-tested in
52
+ * isolation (it is the security-critical gate). A push reaches a subscriber only when BOTH hold:
53
+ * 1. the push's `sessionId` matches the subscriber's requested `sessionId` (tab routing), AND
54
+ * 2. the push's `ownerUserId` matches the subscriber CONNECTION's authenticated identity
55
+ * (`context.userPayload.userRecord.ID`, established once at WS connect).
56
+ *
57
+ * Condition (2) is the fix for the session-hijack class (B49): `sessionId` is a client-chosen
58
+ * correlation value, so a subscriber who lifts another user's `sessionId` would previously receive
59
+ * their pushes. Binding delivery to the connection's server-authenticated identity means knowing a
60
+ * `sessionId` is no longer sufficient. Fails CLOSED — a missing owner or connection identity never
61
+ * matches.
62
+ */
63
+ export declare function statusUpdatesFilter(data: {
64
+ payload: PushStatusNotificationPayload;
65
+ args: PushStatusNotificationArgs;
66
+ context: StatusUpdatesFilterContext | undefined;
67
+ }): boolean;
11
68
  export declare class PushStatusResolver {
12
69
  statusUpdates({ message }: PushStatusNotificationPayload, sessionId: string): PushStatusNotification;
13
70
  }
71
+ export {};
14
72
  //# sourceMappingURL=PushStatusResolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"PushStatusResolver.d.ts","sourceRoot":"","sources":["../../src/generic/PushStatusResolver.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,yBAAyB,wBAAwB,CAAC;AAE/D,qBACa,sBAAsB;IAEjC,OAAO,CAAC,EAAE,MAAM,CAAC;IAGjB,IAAI,EAAG,IAAI,CAAC;IAGZ,SAAS,EAAG,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,6BAA6B;IAC5C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAMD,qBACa,kBAAkB;IAO7B,aAAa,CACH,EAAE,OAAO,EAAE,EAAE,6BAA6B,EAClB,SAAS,EAAE,MAAM,GAChD,sBAAsB;CAG1B"}
1
+ {"version":3,"file":"PushStatusResolver.d.ts","sourceRoot":"","sources":["../../src/generic/PushStatusResolver.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8B,YAAY,EAAoD,MAAM,cAAc,CAAC;AAE1H,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C,eAAO,MAAM,yBAAyB,wBAAwB,CAAC;AAE/D,qBACa,sBAAsB;IAEjC,OAAO,CAAC,EAAE,MAAM,CAAC;IAGjB,IAAI,EAAG,IAAI,CAAC;IAGZ,SAAS,EAAG,MAAM,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,6BAA6B;IAC5C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,kGAAkG;IAClG,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,0BAA0B;IAClC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,mGAAmG;AACnG,MAAM,WAAW,kBAAkB;IACjC,0FAA0F;IAC1F,SAAS,EAAE,MAAM,CAAC;IAClB,0FAA0F;IAC1F,WAAW,EAAE,MAAM,CAAC;IACpB,mGAAmG;IACnG,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,kBAAkB,GAAG,IAAI,CAO1F;AAED,mFAAmF;AACnF,MAAM,WAAW,0BAA0B;IACzC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IACxC,OAAO,EAAE,6BAA6B,CAAC;IACvC,IAAI,EAAE,0BAA0B,CAAC;IACjC,OAAO,EAAE,0BAA0B,GAAG,SAAS,CAAC;CACjD,GAAG,OAAO,CAUV;AAED,qBACa,kBAAkB;IAM7B,aAAa,CACH,EAAE,OAAO,EAAE,EAAE,6BAA6B,EAClB,SAAS,EAAE,MAAM,GAChD,sBAAsB;CAI1B"}
@@ -11,6 +11,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
11
  return function (target, key) { decorator(target, key, paramIndex); }
12
12
  };
13
13
  import { Arg, Field, ID, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
14
+ import { UUIDsEqual } from '@memberjunction/global';
14
15
  export const PUSH_STATUS_UPDATES_TOPIC = 'PUSH_STATUS_UPDATES';
15
16
  let PushStatusNotification = class PushStatusNotification {
16
17
  };
@@ -30,17 +31,55 @@ PushStatusNotification = __decorate([
30
31
  ObjectType()
31
32
  ], PushStatusNotification);
32
33
  export { PushStatusNotification };
34
+ /**
35
+ * The single, centralized vehicle for publishing on {@link PUSH_STATUS_UPDATES_TOPIC}. Every
36
+ * publisher — resolver or service — routes through here (resolvers via the
37
+ * `ResolverBase.PublishStatusUpdate` wrapper; non-resolvers by calling this directly), so the
38
+ * payload shape and the required-identity guarantee live in exactly one place. Adding a field to
39
+ * the push is a one-line change here; a new publisher physically cannot omit `ownerUserId`.
40
+ */
41
+ export function publishStatusUpdate(pubSub, params) {
42
+ const payload = {
43
+ sessionId: params.sessionId,
44
+ ownerUserId: params.ownerUserId,
45
+ message: params.message,
46
+ };
47
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, payload);
48
+ }
49
+ /**
50
+ * Filter predicate for the `statusUpdates` subscription. Exported so it can be unit-tested in
51
+ * isolation (it is the security-critical gate). A push reaches a subscriber only when BOTH hold:
52
+ * 1. the push's `sessionId` matches the subscriber's requested `sessionId` (tab routing), AND
53
+ * 2. the push's `ownerUserId` matches the subscriber CONNECTION's authenticated identity
54
+ * (`context.userPayload.userRecord.ID`, established once at WS connect).
55
+ *
56
+ * Condition (2) is the fix for the session-hijack class (B49): `sessionId` is a client-chosen
57
+ * correlation value, so a subscriber who lifts another user's `sessionId` would previously receive
58
+ * their pushes. Binding delivery to the connection's server-authenticated identity means knowing a
59
+ * `sessionId` is no longer sufficient. Fails CLOSED — a missing owner or connection identity never
60
+ * matches.
61
+ */
62
+ export function statusUpdatesFilter(data) {
63
+ const { payload, args, context } = data;
64
+ if (payload.sessionId !== args.sessionId) {
65
+ return false;
66
+ }
67
+ const connectionUserId = context?.userPayload?.userRecord?.ID;
68
+ if (!connectionUserId || !payload.ownerUserId) {
69
+ return false; // fail closed — no identity on either side means no delivery
70
+ }
71
+ return UUIDsEqual(payload.ownerUserId, connectionUserId);
72
+ }
33
73
  let PushStatusResolver = class PushStatusResolver {
34
74
  statusUpdates({ message }, sessionId) {
75
+ // NOTE: ownerUserId is intentionally NOT returned — it is a server-side filter key only.
35
76
  return { message, date: new Date(), sessionId };
36
77
  }
37
78
  };
38
79
  __decorate([
39
80
  Subscription(() => PushStatusNotification, {
40
81
  topics: PUSH_STATUS_UPDATES_TOPIC,
41
- filter: ({ payload, args, context }) => {
42
- return payload.sessionId === args.sessionId;
43
- },
82
+ filter: (data) => statusUpdatesFilter(data),
44
83
  }),
45
84
  __param(0, Root()),
46
85
  __param(1, Arg('sessionId', () => String)),
@@ -1 +1 @@
1
- {"version":3,"file":"PushStatusResolver.js","sourceRoot":"","sources":["../../src/generic/PushStatusResolver.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAsB,IAAI,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5G,MAAM,CAAC,MAAM,yBAAyB,GAAG,qBAAqB,CAAC;AAGxD,IAAM,sBAAsB,GAA5B,MAAM,sBAAsB;CASlC,CAAA;AAPC;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;uDACvB;AAGjB;IADC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC;8BAChB,IAAI;oDAAC;AAGZ;IADC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,EAAE,CAAC;;yDACF;AARR,sBAAsB;IADlC,UAAU,EAAE;GACA,sBAAsB,CASlC;;AAYM,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;IAO7B,aAAa,CACH,EAAE,OAAO,EAAiC,EAClB,SAAiB;QAEjD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,EAAE,EAAE,SAAS,EAAE,CAAC;IAClD,CAAC;CACF,CAAA;AANC;IANC,YAAY,CAAC,GAAG,EAAE,CAAC,sBAAsB,EAAE;QAC1C,MAAM,EAAE,yBAAyB;QACjC,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAsF,EAAE,EAAE;YACzH,OAAO,OAAO,CAAC,SAAS,KAAK,IAAI,CAAC,SAAS,CAAC;QAC9C,CAAC;KACF,CAAC;IAEC,WAAA,IAAI,EAAE,CAAA;IACN,WAAA,GAAG,CAAC,WAAW,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,CAAA;;;oCAC9B,sBAAsB;uDAExB;AAZU,kBAAkB;IAD9B,QAAQ,EAAE;GACE,kBAAkB,CAa9B"}
1
+ {"version":3,"file":"PushStatusResolver.js","sourceRoot":"","sources":["../../src/generic/PushStatusResolver.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,EAAE,UAAU,EAAgB,QAAQ,EAAsB,IAAI,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC1H,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAGpD,MAAM,CAAC,MAAM,yBAAyB,GAAG,qBAAqB,CAAC;AAGxD,IAAM,sBAAsB,GAA5B,MAAM,sBAAsB;CASlC,CAAA;AAPC;IADC,KAAK,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;uDACvB;AAGjB;IADC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC;8BAChB,IAAI;oDAAC;AAGZ;IADC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,EAAE,CAAC;;yDACF;AARR,sBAAsB;IADlC,UAAU,EAAE;GACA,sBAAsB,CASlC;;AAkCD;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAoB,EAAE,MAA0B;IAClF,MAAM,OAAO,GAAkC;QAC7C,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,OAAO,EAAE,MAAM,CAAC,OAAO;KACxB,CAAC;IACF,MAAM,CAAC,OAAO,CAAC,yBAAyB,EAAE,OAAO,CAAC,CAAC;AACrD,CAAC;AAOD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAInC;IACC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IACxC,IAAI,OAAO,CAAC,SAAS,KAAK,IAAI,CAAC,SAAS,EAAE,CAAC;QACzC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,gBAAgB,GAAuB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,EAAE,CAAC;IAClF,IAAI,CAAC,gBAAgB,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC;QAC9C,OAAO,KAAK,CAAC,CAAC,6DAA6D;IAC7E,CAAC;IACD,OAAO,UAAU,CAAC,OAAO,CAAC,WAAW,EAAE,gBAAgB,CAAC,CAAC;AAC3D,CAAC;AAGM,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;IAM7B,aAAa,CACH,EAAE,OAAO,EAAiC,EAClB,SAAiB;QAEjD,yFAAyF;QACzF,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,EAAE,EAAE,SAAS,EAAE,CAAC;IAClD,CAAC;CACF,CAAA;AAPC;IALC,YAAY,CAAC,GAAG,EAAE,CAAC,sBAAsB,EAAE;QAC1C,MAAM,EAAE,yBAAyB;QACjC,MAAM,EAAE,CAAC,IAA+G,EAAE,EAAE,CAC1H,mBAAmB,CAAC,IAAI,CAAC;KAC5B,CAAC;IAEC,WAAA,IAAI,EAAE,CAAA;IACN,WAAA,GAAG,CAAC,WAAW,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,CAAA;;;oCAC9B,sBAAsB;uDAGxB;AAZU,kBAAkB;IAD9B,QAAQ,EAAE;GACE,kBAAkB,CAa9B"}