@memberjunction/server 5.38.0 → 5.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +67 -0
  2. package/dist/apolloServer/index.d.ts +0 -8
  3. package/dist/apolloServer/index.d.ts.map +1 -1
  4. package/dist/apolloServer/index.js +61 -0
  5. package/dist/apolloServer/index.js.map +1 -1
  6. package/dist/auth/index.js +1 -1
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkKeys.d.ts +58 -0
  9. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -0
  10. package/dist/auth/magicLink/MagicLinkKeys.js +99 -0
  11. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -0
  12. package/dist/auth/magicLink/MagicLinkRouter.d.ts +33 -0
  13. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -0
  14. package/dist/auth/magicLink/MagicLinkRouter.js +184 -0
  15. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -0
  16. package/dist/auth/magicLink/MagicLinkService.d.ts +123 -0
  17. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -0
  18. package/dist/auth/magicLink/MagicLinkService.js +605 -0
  19. package/dist/auth/magicLink/MagicLinkService.js.map +1 -0
  20. package/dist/auth/magicLink/index.d.ts +6 -0
  21. package/dist/auth/magicLink/index.d.ts.map +1 -0
  22. package/dist/auth/magicLink/index.js +6 -0
  23. package/dist/auth/magicLink/index.js.map +1 -0
  24. package/dist/auth/magicLink/magicLinkCore.d.ts +82 -0
  25. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -0
  26. package/dist/auth/magicLink/magicLinkCore.js +164 -0
  27. package/dist/auth/magicLink/magicLinkCore.js.map +1 -0
  28. package/dist/auth/magicLink/redeemLanding.d.ts +22 -0
  29. package/dist/auth/magicLink/redeemLanding.d.ts.map +1 -0
  30. package/dist/auth/magicLink/redeemLanding.js +61 -0
  31. package/dist/auth/magicLink/redeemLanding.js.map +1 -0
  32. package/dist/auth/magicLink/types.d.ts +131 -0
  33. package/dist/auth/magicLink/types.d.ts.map +1 -0
  34. package/dist/auth/magicLink/types.js +6 -0
  35. package/dist/auth/magicLink/types.js.map +1 -0
  36. package/dist/config.d.ts +479 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +84 -0
  39. package/dist/config.js.map +1 -1
  40. package/dist/context.d.ts.map +1 -1
  41. package/dist/context.js +223 -19
  42. package/dist/context.js.map +1 -1
  43. package/dist/generated/generated.d.ts +952 -4
  44. package/dist/generated/generated.d.ts.map +1 -1
  45. package/dist/generated/generated.js +25663 -20321
  46. package/dist/generated/generated.js.map +1 -1
  47. package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
  48. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
  49. package/dist/generic/FireAndForgetHeartbeat.js +44 -0
  50. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
  51. package/dist/generic/ResolverBase.d.ts.map +1 -1
  52. package/dist/generic/ResolverBase.js +35 -7
  53. package/dist/generic/ResolverBase.js.map +1 -1
  54. package/dist/index.d.ts +5 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +145 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/logging/NoLog.d.ts +50 -0
  59. package/dist/logging/NoLog.d.ts.map +1 -0
  60. package/dist/logging/NoLog.js +80 -0
  61. package/dist/logging/NoLog.js.map +1 -0
  62. package/dist/logging/bootAudit.d.ts +43 -0
  63. package/dist/logging/bootAudit.d.ts.map +1 -0
  64. package/dist/logging/bootAudit.js +83 -0
  65. package/dist/logging/bootAudit.js.map +1 -0
  66. package/dist/logging/boundaryLogPayload.d.ts +18 -0
  67. package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
  68. package/dist/logging/boundaryLogPayload.js +18 -0
  69. package/dist/logging/boundaryLogPayload.js.map +1 -0
  70. package/dist/logging/secretRedactor.d.ts +23 -0
  71. package/dist/logging/secretRedactor.d.ts.map +1 -0
  72. package/dist/logging/secretRedactor.js +53 -0
  73. package/dist/logging/secretRedactor.js.map +1 -0
  74. package/dist/logging/shortenForLog.d.ts +8 -0
  75. package/dist/logging/shortenForLog.d.ts.map +1 -0
  76. package/dist/logging/shortenForLog.js +21 -0
  77. package/dist/logging/shortenForLog.js.map +1 -0
  78. package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
  79. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
  80. package/dist/logging/variablesLoggingMiddleware.js +127 -0
  81. package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
  82. package/dist/resolvers/CurrentUserContextResolver.d.ts +9 -3
  83. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -1
  84. package/dist/resolvers/CurrentUserContextResolver.js +19 -5
  85. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -1
  86. package/dist/resolvers/EntityResolver.d.ts.map +1 -1
  87. package/dist/resolvers/EntityResolver.js +13 -2
  88. package/dist/resolvers/EntityResolver.js.map +1 -1
  89. package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts +28 -0
  90. package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts.map +1 -0
  91. package/dist/resolvers/GenerateSeedTaxonomyResolver.js +100 -0
  92. package/dist/resolvers/GenerateSeedTaxonomyResolver.js.map +1 -0
  93. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  94. package/dist/resolvers/GetDataResolver.js +8 -4
  95. package/dist/resolvers/GetDataResolver.js.map +1 -1
  96. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
  97. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  98. package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
  99. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  100. package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
  101. package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
  102. package/dist/resolvers/IntegrationProgressResolver.js +196 -0
  103. package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
  104. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  105. package/dist/resolvers/MCPResolver.js +19 -14
  106. package/dist/resolvers/MCPResolver.js.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  108. package/dist/resolvers/RunAIAgentResolver.js +26 -5
  109. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  110. package/dist/resolvers/RunClusterAnalysisResolver.d.ts +74 -0
  111. package/dist/resolvers/RunClusterAnalysisResolver.d.ts.map +1 -0
  112. package/dist/resolvers/RunClusterAnalysisResolver.js +243 -0
  113. package/dist/resolvers/RunClusterAnalysisResolver.js.map +1 -0
  114. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  115. package/dist/resolvers/RunTestResolver.js +12 -2
  116. package/dist/resolvers/RunTestResolver.js.map +1 -1
  117. package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
  118. package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
  119. package/dist/resolvers/SearchEntitiesResolver.js +216 -0
  120. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
  121. package/dist/resolvers/UserResolver.d.ts +16 -2
  122. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  123. package/dist/resolvers/UserResolver.js +45 -2
  124. package/dist/resolvers/UserResolver.js.map +1 -1
  125. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  126. package/dist/resolvers/VectorizeEntityResolver.js +14 -1
  127. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  128. package/dist/rest/SignatureWebhookHandler.d.ts +19 -0
  129. package/dist/rest/SignatureWebhookHandler.d.ts.map +1 -0
  130. package/dist/rest/SignatureWebhookHandler.js +86 -0
  131. package/dist/rest/SignatureWebhookHandler.js.map +1 -0
  132. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  133. package/dist/services/ScheduledJobsService.js +14 -2
  134. package/dist/services/ScheduledJobsService.js.map +1 -1
  135. package/package.json +79 -74
  136. package/src/__tests__/NoLog.test.ts +76 -0
  137. package/src/__tests__/bootAudit.test.ts +188 -0
  138. package/src/__tests__/boundaryLogPayload.test.ts +31 -0
  139. package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
  140. package/src/__tests__/magicLink.test.ts +387 -0
  141. package/src/__tests__/secretRedactor.test.ts +163 -0
  142. package/src/__tests__/subscriptionRedaction.test.ts +217 -0
  143. package/src/apolloServer/index.ts +58 -0
  144. package/src/auth/index.ts +2 -2
  145. package/src/auth/magicLink/MagicLinkKeys.ts +122 -0
  146. package/src/auth/magicLink/MagicLinkRouter.ts +209 -0
  147. package/src/auth/magicLink/MagicLinkService.ts +724 -0
  148. package/src/auth/magicLink/index.ts +17 -0
  149. package/src/auth/magicLink/magicLinkCore.ts +216 -0
  150. package/src/auth/magicLink/redeemLanding.ts +62 -0
  151. package/src/auth/magicLink/types.ts +137 -0
  152. package/src/config.ts +89 -0
  153. package/src/context.ts +249 -17
  154. package/src/generated/generated.ts +12528 -8866
  155. package/src/generic/FireAndForgetHeartbeat.ts +85 -0
  156. package/src/generic/ResolverBase.ts +35 -7
  157. package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
  158. package/src/index.ts +165 -2
  159. package/src/logging/NoLog.ts +88 -0
  160. package/src/logging/bootAudit.ts +117 -0
  161. package/src/logging/boundaryLogPayload.ts +19 -0
  162. package/src/logging/secretRedactor.ts +82 -0
  163. package/src/logging/shortenForLog.ts +17 -0
  164. package/src/logging/variablesLoggingMiddleware.ts +191 -0
  165. package/src/resolvers/CurrentUserContextResolver.ts +21 -5
  166. package/src/resolvers/EntityResolver.ts +17 -5
  167. package/src/resolvers/GenerateSeedTaxonomyResolver.ts +90 -0
  168. package/src/resolvers/GetDataResolver.ts +9 -5
  169. package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
  170. package/src/resolvers/IntegrationProgressResolver.ts +220 -0
  171. package/src/resolvers/MCPResolver.ts +18 -14
  172. package/src/resolvers/RunAIAgentResolver.ts +28 -5
  173. package/src/resolvers/RunClusterAnalysisResolver.ts +249 -0
  174. package/src/resolvers/RunTestResolver.ts +14 -2
  175. package/src/resolvers/SearchEntitiesResolver.ts +173 -0
  176. package/src/resolvers/UserResolver.ts +38 -2
  177. package/src/resolvers/VectorizeEntityResolver.ts +14 -1
  178. package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
  179. package/src/rest/SignatureWebhookHandler.ts +103 -0
  180. package/src/services/ScheduledJobsService.ts +15 -2
@@ -0,0 +1,117 @@
1
+ import { getMetadataStorage } from 'type-graphql';
2
+ import { configInfo } from '../config.js';
3
+ import { hasNoLogParameter } from './NoLog.js';
4
+
5
+ // Delete is included alongside Create/Update so codegen DeleteMJ*Input resolvers count as
6
+ // metadata-bound and don't flood the audit with false positives (their args are PK + Options
7
+ // only — no encrypted values by construction). Must stay in sync with the redactor's
8
+ // INPUT_TYPE_REGEX in secretRedactor.ts. See docs/adr/0001-graphql-variables-logging-tiered-by-verbose.md.
9
+ const INPUT_TYPE_REGEX = /^(Create|Update|Delete).+Input$/;
10
+
11
+ /**
12
+ * Structural shape of an `@Arg` parameter as seen from outside type-graphql's internals.
13
+ * Used by the audit walker. Mirrors the subset of `ArgParamMetadata` we need.
14
+ */
15
+ export type AuditArgParam = {
16
+ kind: 'arg';
17
+ index: number;
18
+ name: string;
19
+ target: Function;
20
+ methodName: string;
21
+ getType: () => unknown;
22
+ };
23
+
24
+ /**
25
+ * Structural shape of a resolver metadata entry from outside type-graphql's internals.
26
+ * Includes `@Query`, `@Mutation`, `@Subscription` entries.
27
+ */
28
+ export type AuditResolver = {
29
+ target: Function;
30
+ methodName: string;
31
+ params?: ReadonlyArray<{ kind: string; index: number } & Partial<AuditArgParam>>;
32
+ };
33
+
34
+ /**
35
+ * Scans every `@Query` / `@Mutation` / `@Subscription` for `@Arg`s that are neither
36
+ * metadata-bound (input type matching `Create<X>Input` / `Update<X>Input`) nor `@NoLog`-marked.
37
+ * Emits one warning per such arg.
38
+ *
39
+ * Gated on `loggingSettings.graphql.logVariables=true` — runs zero work in default config.
40
+ * In verbose mode, the audit is a diagnostic for the operator who chose to turn on
41
+ * variables logging, naming gaps that will leak plaintext while the flag is active.
42
+ *
43
+ * Never throws. Boot does not fail on a missed `@NoLog`.
44
+ */
45
+ export function auditResolversForUndecoratedArgs(): void {
46
+ if (!configInfo.loggingSettings.graphql.logVariables) {
47
+ return;
48
+ }
49
+
50
+ const storage = getMetadataStorage() as unknown as {
51
+ queries: AuditResolver[];
52
+ mutations: AuditResolver[];
53
+ subscriptions: AuditResolver[];
54
+ };
55
+ auditResolverList([
56
+ ...storage.queries,
57
+ ...storage.mutations,
58
+ ...storage.subscriptions,
59
+ ]);
60
+ }
61
+
62
+ /**
63
+ * Internal helper exported for test injection. Takes a list of resolver metadata directly,
64
+ * skipping the global `getMetadataStorage()` lookup. The boot-time scan uses this after
65
+ * pulling from the global storage; tests pass in a synthetic fixture.
66
+ */
67
+ export function auditResolverList(resolvers: ReadonlyArray<AuditResolver>): void {
68
+ for (const resolver of resolvers) {
69
+ auditResolver(resolver);
70
+ }
71
+ }
72
+
73
+ function auditResolver(resolver: AuditResolver): void {
74
+ for (const param of resolver.params ?? []) {
75
+ if (param.kind !== 'arg') {
76
+ continue;
77
+ }
78
+ auditArg(resolver, param as AuditArgParam);
79
+ }
80
+ }
81
+
82
+ function auditArg(resolver: AuditResolver, argParam: AuditArgParam): void {
83
+ const typeName = readArgTypeName(argParam);
84
+ if (typeName && INPUT_TYPE_REGEX.test(typeName)) {
85
+ return;
86
+ }
87
+ if (hasNoLogParameter(resolver.target, resolver.methodName, argParam.index)) {
88
+ return;
89
+ }
90
+ const resolverName = `${resolver.target.name}.${resolver.methodName}`;
91
+ console.warn(
92
+ `[mj:NoLog audit] Custom resolver ${resolverName} takes argument '${argParam.name}' ` +
93
+ `(type ${typeName ?? 'unknown'}) which is not marked @NoLog. Verify this argument does ` +
94
+ `not carry sensitive material; if it does, apply @NoLog. Variables for this resolver will ` +
95
+ `be logged in plaintext while logVariables=true is active.`,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Reads the declared input type name from an `@Arg` parameter's `getType()` thunk.
101
+ * Returns the class/scalar name when the thunk resolves to a class or named scalar; otherwise
102
+ * returns `undefined` (e.g. for array literals or complex generics we don't unwrap).
103
+ */
104
+ function readArgTypeName(argParam: AuditArgParam): string | undefined {
105
+ try {
106
+ const type = argParam.getType();
107
+ if (typeof type === 'function') {
108
+ return (type as { name?: string }).name;
109
+ }
110
+ if (type && typeof type === 'object' && 'name' in type && typeof (type as { name: unknown }).name === 'string') {
111
+ return (type as { name: string }).name;
112
+ }
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ return undefined;
117
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Builds the always-on GraphQL boundary log payload: operation name ONLY.
3
+ *
4
+ * Variable VALUES are never emitted here, in any configuration — this is the load-bearing
5
+ * fix for the #2638 secret leak. Verbose variables logging (MJ_LOG_GRAPHQL_VARIABLES=true)
6
+ * is the job of `variablesLoggingMiddleware`, which emits per-root-resolver, fully-emitted
7
+ * values with `Encrypt=true` fields masked.
8
+ *
9
+ * Pure function, no MJ-server import surface, so the "no `variables` key in the boundary
10
+ * line" property is testable in isolation without booting the request-context dependency
11
+ * chain.
12
+ *
13
+ * See docs/adr/0001-graphql-variables-logging-tiered-by-verbose.md.
14
+ */
15
+ export function buildBoundaryLogPayload(
16
+ operationName: string | undefined,
17
+ ): { operationName: string | undefined } {
18
+ return { operationName };
19
+ }
@@ -0,0 +1,82 @@
1
+ import type { IMetadataProvider } from '@memberjunction/core';
2
+ import { shortenForLog } from './shortenForLog.js';
3
+
4
+ /**
5
+ * Input to the redactor. Built per `@Arg` per resolver call by the variables-logging middleware.
6
+ *
7
+ * - `inputTypeName` is the GraphQL input type name from the schema (e.g. `"CreateMJCredentialInput"`),
8
+ * derived from `info.parentType.getFields()[info.fieldName].args[i].type` — NOT from any
9
+ * `args[i].constructor.name`, because type-graphql v2.0.0-beta.3 passes raw plain-object args
10
+ * to middleware (conversion to typed instances happens after the middleware chain unwinds).
11
+ * - `provider` is the per-request metadata provider (`context.providers[0].provider`).
12
+ * - `noLogParameter` indicates the `@Arg` itself was decorated `@NoLog` at the parameter level.
13
+ * - `noLogFields` lists field names that were decorated `@NoLog` at the `@Field()` level on the
14
+ * input class. The redactor honors both `EntityFieldInfo.Encrypt=true` (metadata-driven) and
15
+ * this set (decorator-driven) when walking top-level keys.
16
+ */
17
+ export type RedactionContext = {
18
+ inputTypeName: string;
19
+ rawValue: unknown;
20
+ provider: IMetadataProvider;
21
+ noLogParameter: boolean;
22
+ noLogFields: ReadonlySet<string>;
23
+ };
24
+
25
+ // Delete is included so DeleteMJ*Input resolvers resolve to their entity (yielding an empty
26
+ // or value-free encrypted-field walk) instead of falling to the fail-open shortenForLog path —
27
+ // keeps the boot audit quiet on them. Security is identical either way: Delete inputs carry
28
+ // PK + Options only, no encrypted values. Must stay in sync with bootAudit.ts's INPUT_TYPE_REGEX.
29
+ const INPUT_TYPE_REGEX = /^(Create|Update|Delete)(?<name>.+)Input$/;
30
+
31
+ // Assumes EntityFieldInfo.Name === GraphQLFieldName for input-type fields.
32
+ // True today for all codegen output (323 Create*Input + 323 Update*Input).
33
+ // type-graphql allows @Field({ name: 'overrideName' }) to rename fields at the
34
+ // GraphQL layer; MJ codegen does not use this. A future maintainer renaming an
35
+ // encrypted field at the GraphQL layer would silently miss redaction.
36
+ export function redactArg(ctx: RedactionContext): unknown {
37
+ if (ctx.noLogParameter) {
38
+ return '<redacted>';
39
+ }
40
+
41
+ if (ctx.provider.Entities.length === 0) {
42
+ return '<metadata-not-ready>';
43
+ }
44
+
45
+ // Determine the encrypted-field set from entity metadata IF this input maps to a known entity.
46
+ // Custom / non-CRUD inputs (e.g. GetDataInput) have no entity binding — that's fine; field-level
47
+ // @NoLog still applies below. The entity path only adds metadata-driven encrypted-field names.
48
+ let encryptedFieldNames: ReadonlySet<string> = EMPTY_SET;
49
+ const match = INPUT_TYPE_REGEX.exec(ctx.inputTypeName);
50
+ if (match?.groups?.name) {
51
+ const entity = ctx.provider.Entities.find((e) => e.ClassName === match.groups!.name);
52
+ if (entity) {
53
+ encryptedFieldNames = new Set(entity.EncryptedFields.map((f) => f.Name));
54
+ }
55
+ }
56
+
57
+ // Walk top-level keys whenever we have BOTH a redaction source (encrypted fields or @NoLog fields)
58
+ // AND a plain-object value. This honors field-level @NoLog even on non-entity-bound inputs — the
59
+ // exact case @NoLog exists for (custom resolvers, e.g. GetDataInputType.Token). Without a redaction
60
+ // source, or for non-object values, fall through to shortenForLog as before.
61
+ const canWalk =
62
+ (encryptedFieldNames.size > 0 || ctx.noLogFields.size > 0) &&
63
+ ctx.rawValue !== null &&
64
+ typeof ctx.rawValue === 'object' &&
65
+ !Array.isArray(ctx.rawValue);
66
+
67
+ if (!canWalk) {
68
+ return shortenForLog(ctx.rawValue);
69
+ }
70
+
71
+ const result: Record<string, unknown> = {};
72
+ for (const [key, value] of Object.entries(ctx.rawValue as Record<string, unknown>)) {
73
+ if (encryptedFieldNames.has(key) || ctx.noLogFields.has(key)) {
74
+ result[key] = '<redacted>';
75
+ } else {
76
+ result[key] = shortenForLog(value);
77
+ }
78
+ }
79
+ return result;
80
+ }
81
+
82
+ const EMPTY_SET: ReadonlySet<string> = new Set<string>();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Renders a value for one-line console logging without Node's `[Object]` truncation.
3
+ * Arrays keep their structure; non-array objects collapse to JSON, truncated at `maxLen`.
4
+ * Objects whose JSON exceeds `maxLen` and contain nested structure are recursed into so
5
+ * outer keys remain visible.
6
+ */
7
+ export function shortenForLog(value: unknown, maxLen = 300): unknown {
8
+ if (value === null || typeof value !== 'object') return value;
9
+ if (Array.isArray(value)) return value.map((v) => shortenForLog(v, maxLen));
10
+ const json = JSON.stringify(value);
11
+ if (json.length <= maxLen) return json;
12
+ const result: Record<string, unknown> = {};
13
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
14
+ result[k] = shortenForLog(v, maxLen);
15
+ }
16
+ return result;
17
+ }
@@ -0,0 +1,191 @@
1
+ import type { MiddlewareFn } from 'type-graphql';
2
+ import { getMetadataStorage } from 'type-graphql';
3
+ import { getNamedType } from 'graphql';
4
+ import type { GraphQLArgument } from 'graphql';
5
+ import { configInfo } from '../config.js';
6
+ import type { AppContext } from '../types.js';
7
+ import { redactArg } from './secretRedactor.js';
8
+ import { hasNoLogParameter, getNoLogFields } from './NoLog.js';
9
+
10
+ /**
11
+ * Structural shape of a type-graphql `@Arg` parameter metadata entry. Mirrors the
12
+ * subset of `ArgParamMetadata` we read (`kind`, `index`, `name`, `target`, `methodName`)
13
+ * without depending on type-graphql's internal type exports (which are not part of its
14
+ * public API).
15
+ */
16
+ type MinimalArgParamMeta = {
17
+ kind: 'arg';
18
+ index: number;
19
+ name: string;
20
+ target: Function;
21
+ methodName: string;
22
+ };
23
+
24
+ type MinimalParamMeta = MinimalArgParamMeta | { kind: 'args' | 'context' | 'root' | 'info' | 'pubSub' | 'custom'; index: number };
25
+
26
+ type MinimalResolverMeta = {
27
+ schemaName: string;
28
+ target: Function;
29
+ methodName: string;
30
+ params?: MinimalParamMeta[];
31
+ };
32
+
33
+ type ResolverEntry = {
34
+ target: Function;
35
+ methodName: string;
36
+ params: ReadonlyArray<MinimalParamMeta>;
37
+ };
38
+
39
+ let resolverLookup: Map<string, ResolverEntry> | undefined;
40
+
41
+ function getResolverLookup(): Map<string, ResolverEntry> {
42
+ if (resolverLookup !== undefined) {
43
+ return resolverLookup;
44
+ }
45
+ const storage = getMetadataStorage() as unknown as {
46
+ queries: MinimalResolverMeta[];
47
+ mutations: MinimalResolverMeta[];
48
+ subscriptions: MinimalResolverMeta[];
49
+ inputTypes: Array<{ name: string; target: Function }>;
50
+ };
51
+ const lookup = new Map<string, ResolverEntry>();
52
+ const collect = (entries: ReadonlyArray<MinimalResolverMeta>): void => {
53
+ for (const entry of entries) {
54
+ lookup.set(entry.schemaName, {
55
+ target: entry.target,
56
+ methodName: entry.methodName,
57
+ params: entry.params ?? [],
58
+ });
59
+ }
60
+ };
61
+ collect(storage.queries);
62
+ collect(storage.mutations);
63
+ collect(storage.subscriptions);
64
+ resolverLookup = lookup;
65
+ return lookup;
66
+ }
67
+
68
+ function findInputClass(typeName: string): Function | undefined {
69
+ const storage = getMetadataStorage() as unknown as { inputTypes: Array<{ name: string; target: Function }> };
70
+ const found = storage.inputTypes.find((t) => t.name === typeName);
71
+ return found?.target;
72
+ }
73
+
74
+ /** Type guard for `MinimalArgParamMeta`. */
75
+ function isArgParam(p: MinimalParamMeta): p is MinimalArgParamMeta {
76
+ return p.kind === 'arg';
77
+ }
78
+
79
+ const warnedResolverSignatures = new Set<string>();
80
+
81
+ /**
82
+ * Variables-logging middleware. Default-off; gated on `loggingSettings.graphql.logVariables`.
83
+ *
84
+ * NOTE: this middleware is NOT the primary leak defense. The primary defense is the removal
85
+ * of the `variables` field from the always-on log line in `context.ts`. This middleware
86
+ * provides an opt-in, redacted verbose-echo path for developers debugging locally.
87
+ *
88
+ * When enabled, fires once per root resolver call (skips field resolvers via
89
+ * `info.path.prev === undefined`). For each `@Arg`:
90
+ * - Derives the GraphQL input type name from the schema (NOT from `args[i].constructor.name`,
91
+ * because type-graphql v2.0.0-beta.3 passes raw plain-object args to middleware).
92
+ * - Looks up `@NoLog` marks (parameter-level on the resolver, field-level on the input class).
93
+ * - Delegates redaction to `redactArg`.
94
+ *
95
+ * Emits a single `console.dir` line with shape `{ operation, args }`. Note: the key names
96
+ * differ from the always-on log line (`operationName` vs `operation`, `variables` vs `args`)
97
+ * — operators with log-grep automation should update their patterns.
98
+ */
99
+ export const variablesLoggingMiddleware: MiddlewareFn<AppContext> = async (action, next) => {
100
+ // Field resolvers (info.path.prev !== undefined) are skipped — only root @Query/@Mutation/@Subscription.
101
+ if (action.info.path.prev !== undefined) {
102
+ return next();
103
+ }
104
+
105
+ // Hot-path zero-cost short-circuit when the flag is off.
106
+ if (!configInfo.loggingSettings.graphql.logVariables) {
107
+ return next();
108
+ }
109
+
110
+ const provider = action.context.providers?.[0]?.provider;
111
+ if (!provider) {
112
+ return next();
113
+ }
114
+
115
+ const fieldDef = action.info.parentType.getFields()[action.info.fieldName];
116
+ if (!fieldDef) {
117
+ return next();
118
+ }
119
+
120
+ const resolverEntry = getResolverLookup().get(action.info.fieldName);
121
+ const argParams: ReadonlyArray<MinimalArgParamMeta> = (resolverEntry?.params ?? []).filter(isArgParam);
122
+
123
+ const redactedArgs: Record<string, unknown> = {};
124
+ let hasUndecoratedCustomArg = false;
125
+ for (const argDef of fieldDef.args as ReadonlyArray<GraphQLArgument>) {
126
+ const inputTypeName = getNamedType(argDef.type).name;
127
+ const rawValue = action.args[argDef.name];
128
+
129
+ const paramMeta = argParams.find((p) => p.name === argDef.name);
130
+ const noLogParameter = paramMeta && resolverEntry
131
+ ? hasNoLogParameter(resolverEntry.target, resolverEntry.methodName, paramMeta.index)
132
+ : false;
133
+
134
+ const inputClass = findInputClass(inputTypeName);
135
+ const noLogFields = inputClass ? getNoLogFields(inputClass) : new Set<string>();
136
+
137
+ redactedArgs[argDef.name] = redactArg({
138
+ inputTypeName,
139
+ rawValue,
140
+ provider,
141
+ noLogParameter,
142
+ noLogFields,
143
+ });
144
+
145
+ if (!noLogParameter && isCustomArg(inputTypeName, noLogFields, provider)) {
146
+ hasUndecoratedCustomArg = true;
147
+ }
148
+ }
149
+
150
+ // eslint-disable-next-line no-console
151
+ console.dir(
152
+ { operation: action.info.fieldName, args: redactedArgs },
153
+ { depth: null, breakLength: 200 },
154
+ );
155
+
156
+ if (hasUndecoratedCustomArg && resolverEntry) {
157
+ const signature = `${resolverEntry.target.name}.${resolverEntry.methodName}`;
158
+ if (!warnedResolverSignatures.has(signature)) {
159
+ warnedResolverSignatures.add(signature);
160
+ console.warn(
161
+ `[mj:NoLog] Resolver ${signature} has un-decorated, non-metadata-bound argument(s). ` +
162
+ `Variables for this resolver were logged in plaintext while logVariables=true is active. ` +
163
+ `Apply @NoLog to sensitive arguments to suppress this.`,
164
+ );
165
+ }
166
+ }
167
+
168
+ return next();
169
+ };
170
+
171
+ /**
172
+ * Heuristic: an arg is "custom" (i.e. needs `@NoLog` discipline because metadata can't redact it)
173
+ * when its GraphQL input type is not a known `Create<X>Input` / `Update<X>Input` for an entity
174
+ * in the provider, AND no field-level `@NoLog` marks are present on the input class.
175
+ */
176
+ function isCustomArg(
177
+ inputTypeName: string,
178
+ noLogFields: ReadonlySet<string>,
179
+ provider: { Entities: ReadonlyArray<{ ClassName: string }> },
180
+ ): boolean {
181
+ if (noLogFields.size > 0) {
182
+ return false;
183
+ }
184
+ const match = /^(Create|Update)(?<name>.+)Input$/.exec(inputTypeName);
185
+ if (!match || !match.groups?.name) {
186
+ return true;
187
+ }
188
+ const className = match.groups.name;
189
+ const found = provider.Entities.find((e) => e.ClassName === className);
190
+ return !found;
191
+ }
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * Resolver for the `CurrentUserTenantContext` GraphQL query.
3
3
  *
4
- * Returns the server-set `TenantContext` from the authenticated user's `UserInfo`.
5
- * This is populated by post-auth middleware (e.g., BCSaaS's `bcTenantContextMiddleware`)
6
- * and serialized as JSON so the client can auto-stamp `UserInfo.TenantContext`.
4
+ * Returns the server-set `TenantContext` for the authenticated user, serialized
5
+ * as JSON so the client can auto-stamp `UserInfo.TenantContext`.
6
+ *
7
+ * **Resolution order:**
8
+ * 1. Request-scoped context (`userPayload.__bcResolvedTenantContext`) — set by
9
+ * post-auth middleware on the per-request UserPayload. Immune to race conditions
10
+ * when concurrent requests share the same cached UserInfo object.
11
+ * 2. Fallback to `userRecord.TenantContext` — for middleware that stamps UserInfo
12
+ * directly (vanilla MJ deployments or non-BCSaaS middleware).
7
13
  *
8
14
  * On the client, `GraphQLDataProvider.GetCurrentUser()` batches this query alongside
9
15
  * `CurrentUser` — making plugins stack-layer agnostic without any client-side code.
@@ -16,6 +22,9 @@ import { GraphQLJSONObject } from 'graphql-type-json';
16
22
  import { AppContext } from '../types.js';
17
23
  import { ResolverBase } from '../generic/ResolverBase.js';
18
24
 
25
+ /** Well-known key for request-scoped TenantContext on UserPayload */
26
+ const RESOLVED_TENANT_CONTEXT_KEY = '__bcResolvedTenantContext';
27
+
19
28
  @Resolver()
20
29
  export class CurrentUserContextResolver extends ResolverBase {
21
30
  @Query(() => GraphQLJSONObject, {
@@ -27,13 +36,20 @@ export class CurrentUserContextResolver extends ResolverBase {
27
36
  ): Promise<Record<string, unknown> | null> {
28
37
  await this.CheckAPIKeyScopeAuthorization('user:read', '*', context.userPayload);
29
38
 
39
+ // Prefer request-scoped context (set by BCSaaS middleware on userPayload).
40
+ // This avoids race conditions with concurrent requests mutating the shared
41
+ // UserInfo.TenantContext on the same cached UserInfo object from UserCache.
42
+ const requestScoped = (context.userPayload as Record<string, unknown>)[RESOLVED_TENANT_CONTEXT_KEY];
43
+ if (requestScoped && typeof requestScoped === 'object') {
44
+ return { ...(requestScoped as Record<string, unknown>) };
45
+ }
46
+
47
+ // Fallback: read from the shared UserInfo (vanilla MJ / non-BCSaaS middleware)
30
48
  const userRecord = context.userPayload.userRecord;
31
49
  if (!userRecord?.TenantContext) {
32
50
  return null;
33
51
  }
34
52
 
35
- // Serialize the full TenantContext (which may be an extended type like BCTenantContext).
36
- // JSON serialization captures all enumerable properties including those from subtypes.
37
53
  return { ...userRecord.TenantContext } as Record<string, unknown>;
38
54
  }
39
55
  }
@@ -1,10 +1,22 @@
1
1
  import { EntityPermissionType, IRunViewProvider } from '@memberjunction/core';
2
2
  import { AppContext } from '../types.js';
3
- import { Arg, Ctx, Query, Resolver, InputType, Field } from 'type-graphql';
3
+ import { Arg, Ctx, Query, Resolver } from 'type-graphql';
4
4
  import { MJEntity_, MJEntityResolverBase } from '../generated/generated.js';
5
- import sql from 'mssql';
6
5
  import { GetReadOnlyProvider } from '../util.js';
7
6
 
7
+ const VALID_SCHEMA_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
8
+
9
+ function sanitizeSchemaNames(names: string[]): string[] {
10
+ return names.filter((s) => VALID_SCHEMA_NAME.test(s));
11
+ }
12
+
13
+ function buildSchemaInClause(columnName: string, schemas: string[], negate: boolean): string {
14
+ const sanitized = sanitizeSchemaNames(schemas);
15
+ if (sanitized.length === 0) return '';
16
+ const operator = negate ? 'NOT IN' : 'IN';
17
+ return `${columnName} ${operator} (${sanitized.map((s) => `'${s}'`).join(',')})`;
18
+ }
19
+
8
20
  @Resolver(MJEntity_)
9
21
  export class EntityResolver extends MJEntityResolverBase {
10
22
  @Query(() => [MJEntity_])
@@ -17,9 +29,9 @@ export class EntityResolver extends MJEntityResolverBase {
17
29
  const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
18
30
  const rlsWhere = this.getRowLevelSecurityWhereClause(provider, 'Entities', userPayload, EntityPermissionType.Read, ' WHERE');
19
31
  const includeSchemaSQL =
20
- IncludeSchemas && IncludeSchemas.length > 0 ? `SchemaName IN (${IncludeSchemas.map((s) => `'${s}'`).join(',')})` : '';
32
+ IncludeSchemas && IncludeSchemas.length > 0 ? buildSchemaInClause('SchemaName', IncludeSchemas, false) : '';
21
33
  const excludeSchemaSQL =
22
- ExcludeSchemas && ExcludeSchemas.length > 0 ? `SchemaName NOT IN (${ExcludeSchemas.map((s) => `'${s}'`).join(',')})` : '';
34
+ ExcludeSchemas && ExcludeSchemas.length > 0 ? buildSchemaInClause('SchemaName', ExcludeSchemas, true) : '';
23
35
  let schemaSQL = '';
24
36
  if (includeSchemaSQL) schemaSQL = includeSchemaSQL;
25
37
  if (excludeSchemaSQL) {
@@ -32,7 +44,7 @@ export class EntityResolver extends MJEntityResolverBase {
32
44
  if (totalWhere) totalWhere = `${totalWhere} AND ${rlsWhere}`;
33
45
  else totalWhere = ` ${rlsWhere}`;
34
46
  }
35
- const rv = provider as any as IRunViewProvider;
47
+ const rv = provider as IRunViewProvider;
36
48
  const result = await rv.RunView({
37
49
  EntityName: 'MJ: Entities',
38
50
  ExtraFilter: totalWhere,
@@ -0,0 +1,90 @@
1
+ import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Int } from 'type-graphql';
2
+ import { AppContext } from '../types.js';
3
+ import { LogError, LogStatus } from '@memberjunction/core';
4
+ import { ResolverBase } from '../generic/ResolverBase.js';
5
+ import { TagEngine, SeedTaxonomyResult } from '@memberjunction/tag-engine';
6
+
7
+ /* ───── GraphQL output ───── */
8
+
9
+ @ObjectType()
10
+ export class GenerateSeedTaxonomyResult {
11
+ @Field()
12
+ Success: boolean;
13
+
14
+ @Field({ nullable: true })
15
+ ErrorMessage?: string;
16
+
17
+ /** How the taxonomy was produced: 'clustering' or 'prompt-fallback'. */
18
+ @Field({ nullable: true })
19
+ Method?: string;
20
+
21
+ /** Number of content items sampled / considered. */
22
+ @Field(() => Int)
23
+ SampleSize: number;
24
+
25
+ /**
26
+ * JSON-serialized array of SeedTaxonomyNode (the proposed, NON-persisted tree).
27
+ * Kept as a JSON string to keep the GraphQL schema simple, mirroring
28
+ * RunClusterAnalysis's *JSON fields.
29
+ */
30
+ @Field()
31
+ NodesJSON: string;
32
+ }
33
+
34
+ /* ───── Resolver ───── */
35
+
36
+ @Resolver()
37
+ export class GenerateSeedTaxonomyResolver extends ResolverBase {
38
+ /**
39
+ * Propose a hierarchical tag taxonomy for a content source WITHOUT persisting
40
+ * anything. Thin wrapper over {@link TagEngine.generateSeedTaxonomy}.
41
+ *
42
+ * @param sourceID The ContentSource ID whose items seed the taxonomy.
43
+ * @param sampleSize Max number of content items to consider (defaults to 200 when <= 0).
44
+ */
45
+ @Mutation(() => GenerateSeedTaxonomyResult)
46
+ async GenerateSeedTaxonomy(
47
+ @Arg('sourceID', () => String) sourceID: string,
48
+ @Arg('sampleSize', () => Int, { nullable: true }) sampleSize: number,
49
+ @Ctx() { userPayload }: AppContext = {} as AppContext
50
+ ): Promise<GenerateSeedTaxonomyResult> {
51
+ try {
52
+ const currentUser = this.GetUserFromPayload(userPayload);
53
+ if (!currentUser) {
54
+ return this.errorResult('Unable to determine current user');
55
+ }
56
+ if (!sourceID || sourceID.trim().length === 0) {
57
+ return this.errorResult('sourceID is required');
58
+ }
59
+
60
+ LogStatus(`GenerateSeedTaxonomy: sourceID=${sourceID} sampleSize=${sampleSize ?? 'default'}`);
61
+
62
+ const result: SeedTaxonomyResult = await TagEngine.Instance.generateSeedTaxonomy(
63
+ sourceID,
64
+ sampleSize ?? 0,
65
+ currentUser
66
+ );
67
+
68
+ return {
69
+ Success: true,
70
+ Method: result.Method,
71
+ SampleSize: result.SampleSize,
72
+ NodesJSON: JSON.stringify(result.Nodes),
73
+ ErrorMessage: result.Message,
74
+ };
75
+ } catch (error) {
76
+ const msg = error instanceof Error ? error.message : String(error);
77
+ LogError(`GenerateSeedTaxonomy mutation failed: ${msg}`);
78
+ return this.errorResult(msg);
79
+ }
80
+ }
81
+
82
+ private errorResult(message: string): GenerateSeedTaxonomyResult {
83
+ return {
84
+ Success: false,
85
+ ErrorMessage: message,
86
+ SampleSize: 0,
87
+ NodesJSON: '[]',
88
+ };
89
+ }
90
+ }
@@ -1,17 +1,19 @@
1
1
  import { Arg, Ctx, Field, InputType, ObjectType, Query } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
- import { LogError, LogStatus, Metadata } from '@memberjunction/core';
3
+ import { LogError, LogStatus, LogStatusEx, Metadata } from '@memberjunction/core';
4
4
  import { QueryCompositionEngine } from '@memberjunction/generic-database-provider';
5
5
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
6
+ import { NoLog } from '../logging/NoLog.js';
6
7
  import { v4 as uuidv4 } from 'uuid';
7
8
  import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
8
9
  import { getDbType } from '../index.js';
9
10
  import { getSystemUser } from '../auth/index.js';
10
11
  import sql from 'mssql';
11
12
 
12
- @InputType()
13
+ @InputType()
13
14
  export class GetDataInputType {
14
15
  @Field(() => String)
16
+ @NoLog
15
17
  Token: string;
16
18
 
17
19
  @Field(() => [String])
@@ -116,9 +118,11 @@ export class GetDataResolver {
116
118
  @Ctx() context: AppContext
117
119
  ): Promise<GetDataOutputType> {
118
120
  try {
119
- LogStatus(`GetDataResolver.GetData() ---- IMPORTANT - temporarily using the same connection as rest of the server, we need to separately create a READ ONLY CONNECTION and pass that in
120
- the AppContext so we can use that special connection here to ensure we are using a lower privileged connection for this operation to prevent mutation from being possible.`);
121
- LogStatus(`${JSON.stringify(input)}`);
121
+ LogStatusEx({
122
+ message: `GetDataResolver.GetData() ---- IMPORTANT - temporarily using the same connection as rest of the server, we need to separately create a READ ONLY CONNECTION and pass that in the AppContext so we can use that special connection here to ensure we are using a lower privileged connection for this operation to prevent mutation from being possible.`,
123
+ verboseOnly: true,
124
+ });
125
+ LogStatus(`GetData invoked: ${input.Queries.length} queries`);
122
126
 
123
127
  // validate the token
124
128
  if (!isTokenValid(input.Token)) {