@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,217 @@
1
+ /**
2
+ * Middleware wiring test.
3
+ *
4
+ * Locks the assumption that `variablesLoggingMiddleware` correctly:
5
+ * - Skips field resolvers (info.path.prev !== undefined)
6
+ * - Short-circuits when logVariables=false
7
+ * - Emits a redacted variables block when logVariables=true
8
+ * - Respects @NoLog field-level marks via the input class lookup
9
+ *
10
+ * We invoke the middleware directly with a synthetic ResolverData rather than booting a real
11
+ * GraphQL execution — that would force two copies of `graphql` (CJS via type-graphql + ESM via
12
+ * the test runner) into the same realm and fail with "Cannot use GraphQLSchema from another
13
+ * module or realm." The wiring at schema-build time (which IS what we want to lock for future
14
+ * type-graphql upgrades) is exercised in the end-to-end verification described in the PRD;
15
+ * here we just lock the per-call redaction logic.
16
+ *
17
+ * `globalMiddlewares` covers `@Query`, `@Mutation`, and `@Subscription` resolvers identically
18
+ * — they all go through the same `applyMiddlewares` chain inside type-graphql. Asserting the
19
+ * middleware fires once for a representative root resolver covers all three.
20
+ */
21
+ import 'reflect-metadata';
22
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
23
+
24
+ vi.mock('../config', () => ({
25
+ configInfo: {
26
+ loggingSettings: {
27
+ graphql: {
28
+ logVariables: true,
29
+ },
30
+ },
31
+ },
32
+ }));
33
+ vi.mock('../config.js', () => ({
34
+ configInfo: {
35
+ loggingSettings: {
36
+ graphql: {
37
+ logVariables: true,
38
+ },
39
+ },
40
+ },
41
+ }));
42
+
43
+ import { variablesLoggingMiddleware } from '../logging/variablesLoggingMiddleware.js';
44
+
45
+ /**
46
+ * Build a synthetic GraphQLResolveInfo-shaped object that matches what type-graphql passes
47
+ * to a middleware at runtime. Only the subset we read is populated.
48
+ */
49
+ function makeInfo(opts: {
50
+ fieldName: string;
51
+ argTypeName: string;
52
+ argName: string;
53
+ isFieldResolver?: boolean;
54
+ }): unknown {
55
+ const fakeArg = {
56
+ name: opts.argName,
57
+ type: { name: opts.argTypeName },
58
+ };
59
+ const fakeField = {
60
+ args: [fakeArg],
61
+ };
62
+ const parentType = {
63
+ getFields: () => ({ [opts.fieldName]: fakeField }),
64
+ };
65
+ const path = opts.isFieldResolver
66
+ ? { prev: { prev: undefined, key: 'parent' }, key: opts.fieldName }
67
+ : { prev: undefined, key: opts.fieldName };
68
+ return {
69
+ fieldName: opts.fieldName,
70
+ parentType,
71
+ path,
72
+ };
73
+ }
74
+
75
+ // Stub `getNamedType` is unnecessary — middleware uses graphql's `getNamedType` against the
76
+ // type we pass. Our `argTypeName` value is wrapped in a plain `{ name }` object below; we need
77
+ // the middleware's `getNamedType` to read `.name` directly. The `graphql` library's
78
+ // `getNamedType` unwraps NonNull/List wrappers; a plain `{ name }` object survives unwrapping
79
+ // because none of those wrapper shapes are present.
80
+
81
+ describe('variablesLoggingMiddleware', () => {
82
+ let dirSpy: ReturnType<typeof vi.spyOn>;
83
+ let warnSpy: ReturnType<typeof vi.spyOn>;
84
+
85
+ beforeEach(() => {
86
+ dirSpy = vi.spyOn(console, 'dir').mockImplementation(() => {});
87
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
88
+ });
89
+
90
+ afterEach(() => {
91
+ dirSpy.mockRestore();
92
+ warnSpy.mockRestore();
93
+ });
94
+
95
+ it('skips field resolvers (info.path.prev !== undefined) without logging', async () => {
96
+ const next = vi.fn(async () => 'next-result');
97
+ const result = await variablesLoggingMiddleware(
98
+ {
99
+ root: undefined,
100
+ args: { x: 1 },
101
+ context: {} as never,
102
+ info: makeInfo({
103
+ fieldName: 'nestedThing',
104
+ argTypeName: 'CreateMJCredentialInput',
105
+ argName: 'input',
106
+ isFieldResolver: true,
107
+ }) as never,
108
+ },
109
+ next,
110
+ );
111
+ expect(next).toHaveBeenCalledTimes(1);
112
+ expect(result).toBe('next-result');
113
+ expect(dirSpy).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it('emits a redacted variables block for a root resolver with an entity-bound CRUD input', async () => {
117
+ const next = vi.fn(async () => 'next-result');
118
+ const fakeProvider = {
119
+ Entities: [
120
+ { ClassName: 'MJCredential', EncryptedFields: [{ Name: 'Values' }] },
121
+ ],
122
+ };
123
+
124
+ await variablesLoggingMiddleware(
125
+ {
126
+ root: undefined,
127
+ args: { input: { ID: 'abc', Name: 'HubSpot', Values: 'FAKE_SECRET' } },
128
+ context: {
129
+ providers: [{ provider: fakeProvider, type: 'Read-Write' }],
130
+ } as never,
131
+ info: makeInfo({
132
+ fieldName: 'CreateMJCredential',
133
+ argTypeName: 'CreateMJCredentialInput',
134
+ argName: 'input',
135
+ }) as never,
136
+ },
137
+ next,
138
+ );
139
+
140
+ expect(next).toHaveBeenCalledTimes(1);
141
+ expect(dirSpy).toHaveBeenCalledTimes(1);
142
+ const [logged] = dirSpy.mock.calls[0];
143
+ expect(logged).toEqual({
144
+ operation: 'CreateMJCredential',
145
+ args: {
146
+ input: {
147
+ ID: 'abc',
148
+ Name: 'HubSpot',
149
+ Values: '<redacted>',
150
+ },
151
+ },
152
+ });
153
+ });
154
+
155
+ it('logs a custom-resolver plaintext arg (no metadata redaction available for it)', async () => {
156
+ const next = vi.fn(async () => undefined);
157
+ // Populate Entities (non-empty) so the redactor does not fall into the bootstrap fail-closed branch.
158
+ // The String-typed arg doesn't match Create/Update<X>Input, so it falls through to shortenForLog.
159
+ const fakeProvider = {
160
+ Entities: [{ ClassName: 'SomeUnrelatedEntity', EncryptedFields: [] }],
161
+ };
162
+
163
+ await variablesLoggingMiddleware(
164
+ {
165
+ root: undefined,
166
+ args: { token: 'plaintext-leak' },
167
+ context: {
168
+ providers: [{ provider: fakeProvider, type: 'Read-Write' }],
169
+ } as never,
170
+ info: makeInfo({
171
+ fieldName: 'VoiceTestHubSpotCredential',
172
+ argTypeName: 'String',
173
+ argName: 'token',
174
+ }) as never,
175
+ },
176
+ next,
177
+ );
178
+
179
+ expect(dirSpy).toHaveBeenCalledTimes(1);
180
+ // Arg is logged as-is because String type doesn't match Create/Update<X>Input, so the
181
+ // redactor's fail-open branch passes the value through shortenForLog (plain string passthrough).
182
+ // This is the gap that @NoLog discipline plus the boot audit are designed to surface.
183
+ const [logged] = dirSpy.mock.calls[0];
184
+ expect(logged).toMatchObject({
185
+ operation: 'VoiceTestHubSpotCredential',
186
+ args: { token: 'plaintext-leak' },
187
+ });
188
+ });
189
+
190
+ it('fails closed (<metadata-not-ready>) when provider.Entities is empty', async () => {
191
+ const next = vi.fn(async () => undefined);
192
+ const fakeProvider = { Entities: [] };
193
+
194
+ await variablesLoggingMiddleware(
195
+ {
196
+ root: undefined,
197
+ args: { input: { Values: 'FAKE_SECRET' } },
198
+ context: {
199
+ providers: [{ provider: fakeProvider, type: 'Read-Write' }],
200
+ } as never,
201
+ info: makeInfo({
202
+ fieldName: 'CreateMJCredential',
203
+ argTypeName: 'CreateMJCredentialInput',
204
+ argName: 'input',
205
+ }) as never,
206
+ },
207
+ next,
208
+ );
209
+
210
+ expect(dirSpy).toHaveBeenCalledTimes(1);
211
+ const [logged] = dirSpy.mock.calls[0];
212
+ expect(logged).toEqual({
213
+ operation: 'CreateMJCredential',
214
+ args: { input: '<metadata-not-ready>' },
215
+ });
216
+ });
217
+ });
@@ -15,6 +15,63 @@ import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
15
15
  * @param servers - HTTP server and WebSocket cleanup disposable
16
16
  * @param additionalPlugins - Optional additional plugins to merge with built-in plugins
17
17
  */
18
+ /**
19
+ * Apollo plugin: log every Integration* GraphQL operation to stdout as
20
+ * structured JSON. Lets operators tailing the MJAPI log see exactly which
21
+ * resolver the wizard / Explorer hit on each press, with arg names (values
22
+ * redacted for credentials). Diagnostic-only — no behavior change.
23
+ *
24
+ * Emits two events per operation:
25
+ * {"event":"gql.integration.request","method":"...","fieldName":"...","argNames":[...]}
26
+ * {"event":"gql.integration.response","method":"...","fieldName":"...","durationMs":N,"hasErrors":bool}
27
+ *
28
+ * Filter from the log:
29
+ * tail -f /tmp/mjapi.log | grep '"event":"gql\.integration\.'
30
+ */
31
+ const integrationOperationTracer: ApolloServerPlugin = {
32
+ async requestDidStart(requestCtx) {
33
+ // Off by default — the plugin stays registered but emits nothing unless the
34
+ // MJ_INTEGRATION_TRACE debug flag is explicitly set. Avoids logging on every
35
+ // request in normal operation.
36
+ if (process.env.MJ_INTEGRATION_TRACE !== 'true') return undefined;
37
+ const opName = requestCtx.request.operationName ?? 'anonymous';
38
+ const query = requestCtx.request.query ?? '';
39
+ // Lightweight match — fire only for operations that touch an Integration
40
+ // resolver field. Avoid logging every Color / User / Task query.
41
+ if (!query.includes('Integration')) return undefined;
42
+ const startedAt = Date.now();
43
+ return {
44
+ async willSendResponse(rc) {
45
+ // Walk the fields the operation queried; only those starting with
46
+ // "Integration" matter. Variables surfaced as keys only.
47
+ const op = rc.operation;
48
+ if (!op) return;
49
+ const integrationFields: string[] = [];
50
+ for (const sel of op.selectionSet.selections) {
51
+ if (sel.kind === 'Field' && sel.name.value.startsWith('Integration')) {
52
+ integrationFields.push(sel.name.value);
53
+ }
54
+ }
55
+ if (integrationFields.length === 0) return;
56
+ const argNames = Object.keys(rc.request.variables ?? {});
57
+ const durationMs = Date.now() - startedAt;
58
+ const hasErrors = !!(rc.response.body.kind === 'single' && rc.response.body.singleResult.errors?.length);
59
+ for (const field of integrationFields) {
60
+ console.log(JSON.stringify({
61
+ ts: new Date().toISOString(),
62
+ event: 'gql.integration.response',
63
+ operationName: opName,
64
+ method: field,
65
+ argNames,
66
+ durationMs,
67
+ hasErrors,
68
+ }));
69
+ }
70
+ },
71
+ };
72
+ },
73
+ };
74
+
18
75
  const buildApolloServer = (
19
76
  configOverride: ApolloServerOptions<AppContext>,
20
77
  { httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable },
@@ -22,6 +79,7 @@ const buildApolloServer = (
22
79
  ) => {
23
80
  const builtInPlugins: ApolloServerPlugin[] = [
24
81
  ApolloServerPluginDrainHttpServer({ httpServer }),
82
+ integrationOperationTracer,
25
83
  {
26
84
  async serverWillStart() {
27
85
  return {
package/src/auth/index.ts CHANGED
@@ -235,13 +235,13 @@ export const verifyUserRecord = async (
235
235
  // to init it, including passing in the role list for the user.
236
236
  const md: Metadata = new Metadata(); // global-provider-ok: JWT validation + role lookup runs BEFORE AppContext.providers is built — no per-request provider yet
237
237
 
238
- const initData: MJUserEntityType & { UserRoles: { UserID: string; RoleName: string; RoleID: string }[] } = newUser.GetAll();
238
+ const initData: MJUserEntityType & { UserRoles: { UserID: string; Role: string; RoleID: string }[] } = newUser.GetAll();
239
239
 
240
240
  initData.UserRoles = configInfo.userHandling.newUserRoles.map((role) => {
241
241
  const roleInfo: RoleInfo | undefined = md.Roles.find((r) => r.Name === role);
242
242
  const roleID: string = roleInfo ? roleInfo.ID : '';
243
243
 
244
- return { UserID: initData.ID, RoleName: role, RoleID: roleID };
244
+ return { UserID: initData.ID, Role: role, RoleID: roleID };
245
245
  });
246
246
 
247
247
  user = new UserInfo(Metadata.Provider, initData); // global-provider-ok: same JWT-validation context — no per-request provider yet
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @fileoverview RS256 key manager for magic-link session JWTs.
3
+ *
4
+ * MJ signs magic-link session tokens with an RS256 private key and publishes the
5
+ * matching public key at a JWKS endpoint. Registering the `magic-link` auth
6
+ * provider with that JWKS URL lets MJServer's standard issuer-driven validation
7
+ * verify the tokens with no special-casing.
8
+ *
9
+ * @module @memberjunction/server/auth/magicLink
10
+ */
11
+
12
+ import { BaseSingleton } from '@memberjunction/global';
13
+ import { LogStatus } from '@memberjunction/core';
14
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, createHash, type KeyObject } from 'node:crypto';
15
+ import jwt from 'jsonwebtoken';
16
+ import type { MagicLinkJWTClaims } from './types.js';
17
+
18
+ /** A single JSON Web Key plus the metadata jwks-rsa needs to match it. */
19
+ interface PublishedJWK extends JsonWebKey {
20
+ kid: string;
21
+ use: 'sig';
22
+ alg: 'RS256';
23
+ }
24
+
25
+ /**
26
+ * Manages the RS256 keypair used to sign and publish magic-link session tokens.
27
+ * Singleton so the keypair (and therefore the `kid`) is stable for the process
28
+ * lifetime — restarting with an ephemeral key invalidates outstanding sessions,
29
+ * which is the intended dev behavior and an acceptable production key-rotation
30
+ * mechanism.
31
+ */
32
+ export class MagicLinkKeyManager extends BaseSingleton<MagicLinkKeyManager> {
33
+ private privateKeyPem = '';
34
+ private publicKey?: KeyObject;
35
+ private _kid = '';
36
+ private _initialized = false;
37
+
38
+ // Public to satisfy BaseSingleton.getInstance's `this: new () => T` constraint
39
+ // (matches the AuthProviderFactory singleton pattern).
40
+ public constructor() {
41
+ super();
42
+ }
43
+
44
+ public static get Instance(): MagicLinkKeyManager {
45
+ return MagicLinkKeyManager.getInstance<MagicLinkKeyManager>();
46
+ }
47
+
48
+ public get IsInitialized(): boolean {
49
+ return this._initialized;
50
+ }
51
+
52
+ public get Kid(): string {
53
+ return this._kid;
54
+ }
55
+
56
+ /**
57
+ * Initializes the keypair from a provided PEM (raw or base64-encoded) or, if
58
+ * none is supplied, generates an ephemeral 2048-bit RSA keypair. Idempotent.
59
+ */
60
+ public Initialize(privateKeyPem?: string): void {
61
+ if (this._initialized) {
62
+ return;
63
+ }
64
+
65
+ let priv: KeyObject;
66
+ const supplied = privateKeyPem?.trim();
67
+ if (supplied) {
68
+ priv = createPrivateKey(this.normalizePem(supplied));
69
+ } else {
70
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
71
+ priv = privateKey;
72
+ LogStatus(
73
+ '[MagicLink] No rsaPrivateKey configured — generated an EPHEMERAL RS256 keypair. ' +
74
+ 'Outstanding magic-link sessions will be invalidated on restart. Set magicLink.rsaPrivateKey ' +
75
+ '(or MJ_MAGIC_LINK_PRIVATE_KEY) for stable production keys.'
76
+ );
77
+ }
78
+
79
+ this.privateKeyPem = priv.export({ type: 'pkcs8', format: 'pem' }) as string;
80
+ this.publicKey = createPublicKey(priv);
81
+ this._kid = this.computeKid(this.publicKey);
82
+ this._initialized = true;
83
+ }
84
+
85
+ /**
86
+ * Signs a set of magic-link claims and returns the compact JWT.
87
+ * `iat`/`exp` are expected to already be set on the claims.
88
+ */
89
+ public Sign(claims: MagicLinkJWTClaims): string {
90
+ this.ensureInitialized();
91
+ return jwt.sign(claims, this.privateKeyPem, { algorithm: 'RS256', keyid: this._kid });
92
+ }
93
+
94
+ /**
95
+ * Returns the JWKS document (public key set) for the JWKS endpoint.
96
+ */
97
+ public GetJWKS(): { keys: PublishedJWK[] } {
98
+ this.ensureInitialized();
99
+ const jwk = this.publicKey!.export({ format: 'jwk' });
100
+ return { keys: [{ ...jwk, kid: this._kid, use: 'sig', alg: 'RS256' }] };
101
+ }
102
+
103
+ private ensureInitialized(): void {
104
+ if (!this._initialized) {
105
+ throw new Error('MagicLinkKeyManager not initialized — call Initialize() during server startup.');
106
+ }
107
+ }
108
+
109
+ /** Accepts raw PEM or base64-encoded PEM and returns raw PEM. */
110
+ private normalizePem(value: string): string {
111
+ if (value.includes('-----BEGIN')) {
112
+ return value;
113
+ }
114
+ return Buffer.from(value, 'base64').toString('utf8');
115
+ }
116
+
117
+ /** Stable key id derived from the public key (SHA-256 of DER, base64url, truncated). */
118
+ private computeKid(publicKey: KeyObject): string {
119
+ const pem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
120
+ return createHash('sha256').update(pem).digest('base64url').slice(0, 16);
121
+ }
122
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @fileoverview Express wiring + auth-provider registration for magic links.
3
+ *
4
+ * Mirrors the OAuth handler pattern: a public router (unauthenticated — JWKS +
5
+ * redeem) mounted BEFORE the unified auth middleware, and an authenticated
6
+ * router (invite creation) mounted AFTER it so `req.userPayload` is available.
7
+ *
8
+ * @module @memberjunction/server/auth/magicLink
9
+ */
10
+
11
+ import { Router, json, urlencoded, type Request, type Response } from 'express';
12
+ import { rateLimit } from 'express-rate-limit';
13
+ import { LogError, LogStatus, type AuthProviderConfig } from '@memberjunction/core';
14
+ import { AuthProviderFactory } from '@memberjunction/auth-providers';
15
+ import { configInfo, type MagicLinkConfig } from '../../config.js';
16
+ import { MagicLinkKeyManager } from './MagicLinkKeys.js';
17
+ import { MagicLinkService } from './MagicLinkService.js';
18
+ import { buildRedeemLandingHtml } from './redeemLanding.js';
19
+ import type { CreateMagicLinkInviteParams, RedeemMagicLinkResult } from './types.js';
20
+
21
+ /** The mount path for both routers (`/magic-link`). */
22
+ export const MAGIC_LINK_MOUNT_PATH = '/magic-link';
23
+
24
+ /** Path of the JWKS endpoint relative to the public URL. */
25
+ export const MAGIC_LINK_JWKS_PATH = `${MAGIC_LINK_MOUNT_PATH}/jwks.json`;
26
+
27
+ /**
28
+ * Builds the magic-link routers. The caller mounts `publicRouter` before the
29
+ * auth middleware and `authenticatedRouter` after it (both at MAGIC_LINK_MOUNT_PATH).
30
+ */
31
+ export function createMagicLinkHandler(publicUrl: string, config: MagicLinkConfig): {
32
+ publicRouter: Router;
33
+ authenticatedRouter: Router;
34
+ } {
35
+ // Initialize the signing keypair once.
36
+ MagicLinkKeyManager.Instance.Initialize(config.rsaPrivateKey);
37
+
38
+ const service = new MagicLinkService(publicUrl, config);
39
+
40
+ // Throttle the unauthenticated redemption endpoint and the authenticated
41
+ // create endpoint. /redeem is a public surface that does a DB lookup +
42
+ // (on a hit) user provisioning + token minting, so it must be rate-limited
43
+ // against guessing/enumeration/resource-exhaustion. Keyed by IP.
44
+ const redeemLimiter = rateLimit({
45
+ windowMs: config.rateLimitWindowMs,
46
+ limit: config.redeemRateLimitMax,
47
+ standardHeaders: 'draft-7',
48
+ legacyHeaders: false,
49
+ message: { success: false, errorCode: 'invalid', error: 'Too many redemption attempts. Try again later.' },
50
+ });
51
+ const createLimiter = rateLimit({
52
+ windowMs: config.rateLimitWindowMs,
53
+ limit: config.createRateLimitMax,
54
+ standardHeaders: 'draft-7',
55
+ legacyHeaders: false,
56
+ message: { success: false, error: 'Too many invite requests. Try again later.' },
57
+ });
58
+
59
+ // ── Public (unauthenticated) ──────────────────────────────────────────────
60
+ const publicRouter = Router();
61
+
62
+ publicRouter.get('/jwks.json', (_req: Request, res: Response) => {
63
+ res.status(200).json(MagicLinkKeyManager.Instance.GetJWKS());
64
+ });
65
+
66
+ // Sends a completed redemption result: browser flow → 302 into Explorer with
67
+ // the session token in the URL fragment (never sent to a server, so it stays
68
+ // out of access/proxy logs); API flow → JSON with an appropriate status.
69
+ const sendRedeemResult = (res: Response, result: RedeemMagicLinkResult, wantsJson: boolean): void => {
70
+ const explorerUrl = config.explorerUrl?.replace(/\/$/, '');
71
+ if (explorerUrl && !wantsJson) {
72
+ if (result.success && result.token) {
73
+ // Deep-link into the invited app's route (e.g. /app/data-explorer) rather
74
+ // than Explorer's root — root falls through to Explorer's default resource,
75
+ // which isn't the scoped app. Use the app's URL Path (what Explorer's
76
+ // GetAppByPath matches on); fall back to a name-slug only when Path is
77
+ // absent. Do NOT hand-roll a slug from the name when Path exists — the two
78
+ // can diverge and Explorer would fail to resolve the app.
79
+ const appSlug = result.applicationPath
80
+ ? result.applicationPath
81
+ : result.applicationName
82
+ ? result.applicationName.trim().toLowerCase().replace(/\s+/g, '-')
83
+ : '';
84
+ const path = appSlug ? `/app/${encodeURIComponent(appSlug)}` : '/';
85
+ res.redirect(302, `${explorerUrl}${path}#token=${encodeURIComponent(result.token)}`);
86
+ } else {
87
+ res.redirect(302, `${explorerUrl}/?mlError=${encodeURIComponent(result.errorCode ?? 'invalid')}`);
88
+ }
89
+ return;
90
+ }
91
+ // API/JSON flow.
92
+ // not_found / expired / consumed / revoked are client-side conditions (410 Gone);
93
+ // server_error / provisioning_failed are 500.
94
+ const status = result.success ? 200 : result.errorCode === 'server_error' || result.errorCode === 'provisioning_failed' ? 500 : 410;
95
+ res.status(status).json(result);
96
+ };
97
+
98
+ // GET /redeem is SAFE (no side effects). Link prefetchers and email security
99
+ // scanners routinely fetch URLs; a side-effectful GET would let them burn the
100
+ // single-use token before the human clicks. So GET renders an interstitial and
101
+ // the actual redemption happens only on POST (below), gated by a user click.
102
+ publicRouter.get('/redeem', redeemLimiter, (req: Request, res: Response) => {
103
+ const token = typeof req.query.token === 'string' ? req.query.token : '';
104
+ if (!token) {
105
+ res.status(400).json({ success: false, error: 'Missing token.' });
106
+ return;
107
+ }
108
+ // API callers must POST — GET never redeems.
109
+ if (req.query.format === 'json') {
110
+ res.status(405).json({ success: false, error: 'Redemption requires POST.', method: 'POST', path: `${MAGIC_LINK_MOUNT_PATH}/redeem` });
111
+ return;
112
+ }
113
+ res
114
+ .status(200)
115
+ .type('html')
116
+ .send(buildRedeemLandingHtml(token, `${MAGIC_LINK_MOUNT_PATH}/redeem`));
117
+ });
118
+
119
+ // POST /redeem performs the actual (side-effectful) redemption. Token arrives
120
+ // from the interstitial form body, an API client's JSON body, or the query.
121
+ publicRouter.post('/redeem', redeemLimiter, urlencoded({ extended: false }), json(), async (req: Request, res: Response) => {
122
+ const body = (req.body ?? {}) as { token?: string };
123
+ const token =
124
+ (typeof body.token === 'string' && body.token) ||
125
+ (typeof req.query.token === 'string' ? req.query.token : '');
126
+ const wantsJson = req.query.format === 'json' || req.is('application/json') === 'application/json';
127
+
128
+ if (!token) {
129
+ res.status(400).json({ success: false, error: 'Missing token.' });
130
+ return;
131
+ }
132
+
133
+ // Forensic context for the redemption audit trail. `req.ip` honors the app's
134
+ // trust-proxy setting; headers are best-effort and may be absent for API clients.
135
+ const result = await service.RedeemInvite(token, {
136
+ ipAddress: req.ip,
137
+ userAgent: req.get('user-agent') ?? undefined,
138
+ origin: req.get('origin') ?? undefined,
139
+ });
140
+ sendRedeemResult(res, result, wantsJson);
141
+ });
142
+
143
+ // ── Authenticated (invite creation) ───────────────────────────────────────
144
+ const authenticatedRouter = Router();
145
+
146
+ authenticatedRouter.post('/create', createLimiter, json(), async (req: Request, res: Response) => {
147
+ const creatingUser = req.userPayload?.userRecord;
148
+ if (!creatingUser) {
149
+ res.status(401).json({ success: false, error: 'Authentication required.' });
150
+ return;
151
+ }
152
+
153
+ const body = (req.body ?? {}) as Partial<CreateMagicLinkInviteParams>;
154
+ if (!body.email || !body.applicationId) {
155
+ res.status(400).json({ success: false, error: 'email and applicationId are required.' });
156
+ return;
157
+ }
158
+
159
+ const result = await service.CreateInvite(
160
+ {
161
+ email: body.email,
162
+ applicationId: body.applicationId,
163
+ roleId: body.roleId,
164
+ expiresInHours: body.expiresInHours,
165
+ maxUses: body.maxUses,
166
+ firstName: body.firstName,
167
+ lastName: body.lastName,
168
+ },
169
+ creatingUser,
170
+ );
171
+ // forbidden → 403 (caller not allowed to issue invites); invalid_role and
172
+ // other validation failures → 400.
173
+ const status = result.success ? 200 : result.errorCode === 'forbidden' ? 403 : 400;
174
+ res.status(status).json(result);
175
+ });
176
+
177
+ return { publicRouter, authenticatedRouter };
178
+ }
179
+
180
+ /**
181
+ * Registers the `magic-link` auth provider so MJServer's issuer-driven JWT
182
+ * validation accepts MJ-issued session tokens. Issuer = public URL, JWKS URL =
183
+ * the public JWKS endpoint, audience = configured magic-link audience.
184
+ *
185
+ * Also appends the provider to `configInfo.authProviders` so a later
186
+ * `initializeAuthProviders()` (which clears + repopulates from config) keeps it.
187
+ */
188
+ export function registerMagicLinkAuthProvider(publicUrl: string, config: MagicLinkConfig): void {
189
+ const base = publicUrl.replace(/\/$/, '');
190
+ const providerConfig: AuthProviderConfig = {
191
+ name: 'magic-link',
192
+ type: 'magic-link',
193
+ issuer: publicUrl,
194
+ audience: config.audience,
195
+ jwksUri: `${base}${MAGIC_LINK_JWKS_PATH}`,
196
+ };
197
+
198
+ try {
199
+ configInfo.authProviders = configInfo.authProviders ?? [];
200
+ if (!configInfo.authProviders.some((p) => p.type === 'magic-link')) {
201
+ configInfo.authProviders.push(providerConfig);
202
+ }
203
+ const factory = AuthProviderFactory.Instance;
204
+ factory.register(AuthProviderFactory.createProvider(providerConfig));
205
+ LogStatus(`[MagicLink] Registered auth provider (issuer: ${publicUrl}, jwks: ${providerConfig.jwksUri})`);
206
+ } catch (e) {
207
+ LogError(`[MagicLink] Failed to register auth provider: ${e instanceof Error ? e.message : String(e)}`);
208
+ }
209
+ }