@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,84 @@
1
+ import 'reflect-metadata';
2
+ import { describe, it, expect } from 'vitest';
3
+ import type { IMetadataProvider } from '@memberjunction/core';
4
+ import { redactArg } from '../logging/secretRedactor.js';
5
+ import { NoLog, getNoLogFields } from '../logging/NoLog.js';
6
+
7
+ /**
8
+ * Test 4 (unit form): the `@NoLog`-decorated `Token` field on GetDataInputType is redacted
9
+ * from verbose logs, while the non-sensitive `Queries` field passes through.
10
+ *
11
+ * GetData is @RequireSystemUser (system-user-only), so this is verified at the redactor level
12
+ * rather than via a live request. It exercises the field-level @NoLog path: the middleware
13
+ * collects @NoLog field names via getNoLogFields() and passes them to redactArg as noLogFields.
14
+ *
15
+ * GetDataInputType is NOT a Create/Update/Delete*Input, so it fails the entity-binding regex and
16
+ * would otherwise pass through whole — the @NoLog field set is the ONLY thing protecting Token here.
17
+ * That's exactly the case @NoLog exists for: sensitive args metadata cannot identify.
18
+ */
19
+ describe('Test 4: GetData Token redaction via field-level @NoLog', () => {
20
+ // Mirror the real GetDataInputType shape and apply @NoLog to Token at field level, as the source does.
21
+ class GetDataInputType {
22
+ Token = '';
23
+ Queries: string[] = [];
24
+ }
25
+ NoLog(GetDataInputType.prototype, 'Token');
26
+
27
+ const provider = { Entities: [] } as unknown as IMetadataProvider;
28
+
29
+ it('getNoLogFields picks up the Token field mark', () => {
30
+ const fields = getNoLogFields(GetDataInputType);
31
+ expect(fields.has('Token')).toBe(true);
32
+ expect(fields.has('Queries')).toBe(false);
33
+ });
34
+
35
+ it('masks field-level @NoLog Token on a NON-entity-bound input (the real GetData case)', () => {
36
+ // GetDataInput does NOT match Create/Update/Delete*Input. This is the exact case @NoLog exists
37
+ // for: a custom resolver input metadata cannot identify. The redactor must still honor the
38
+ // field-level @NoLog set and mask Token, while passing the non-sensitive Queries through.
39
+ const providerWithEntities = {
40
+ Entities: [{ ClassName: 'Something', EncryptedFields: [] }],
41
+ } as unknown as IMetadataProvider;
42
+
43
+ const result = redactArg({
44
+ inputTypeName: 'GetDataInput', // not entity-bound — regex won't match
45
+ rawValue: { Token: 'FAKE_SYSTEM_TOKEN_DO_NOT_USE', Queries: ['SELECT 1', 'SELECT 2'] },
46
+ provider: providerWithEntities,
47
+ noLogParameter: false,
48
+ noLogFields: getNoLogFields(GetDataInputType),
49
+ });
50
+
51
+ expect(result).toEqual({ Token: '<redacted>', Queries: ['SELECT 1', 'SELECT 2'] });
52
+ expect(JSON.stringify(result)).not.toContain('FAKE_SYSTEM_TOKEN_DO_NOT_USE');
53
+ });
54
+
55
+ it('redactArg masks Token field-by-field when input IS entity-bound (noLogFields wins over metadata)', () => {
56
+ // When the input type maps to an entity, redactArg walks top-level keys and applies noLogFields.
57
+ const providerWithEntity = {
58
+ Entities: [{ ClassName: 'GetDataThing', EncryptedFields: [] }],
59
+ } as unknown as IMetadataProvider;
60
+
61
+ const result = redactArg({
62
+ inputTypeName: 'CreateGetDataThingInput',
63
+ rawValue: { Token: 'FAKE_SYSTEM_TOKEN_DO_NOT_USE', Queries: ['SELECT 1'] },
64
+ provider: providerWithEntity,
65
+ noLogParameter: false,
66
+ noLogFields: new Set(['Token']),
67
+ });
68
+
69
+ expect(result).toEqual({ Token: '<redacted>', Queries: ['SELECT 1'] });
70
+ expect(JSON.stringify(result)).not.toContain('FAKE_SYSTEM_TOKEN_DO_NOT_USE');
71
+ });
72
+
73
+ it('parameter-level @NoLog masks the entire arg regardless of shape', () => {
74
+ // If Token were a top-level @Arg with @NoLog (parameter form), noLogParameter=true masks it whole.
75
+ const result = redactArg({
76
+ inputTypeName: 'String',
77
+ rawValue: 'FAKE_SYSTEM_TOKEN_DO_NOT_USE',
78
+ provider,
79
+ noLogParameter: true,
80
+ noLogFields: new Set(),
81
+ });
82
+ expect(result).toBe('<redacted>');
83
+ });
84
+ });
@@ -0,0 +1,387 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import jwt from 'jsonwebtoken';
3
+ import { createPublicKey } from 'node:crypto';
4
+ import {
5
+ generateRawToken,
6
+ hashToken,
7
+ evaluateInvite,
8
+ buildSessionClaims,
9
+ buildConsumeInviteSQL,
10
+ canIssueInvites,
11
+ isRoleGrantable,
12
+ unionScopes,
13
+ MAGIC_LINK_TOKEN_PREFIX,
14
+ } from '../auth/magicLink/magicLinkCore.js';
15
+ import { buildRedeemLandingHtml, escapeHtml } from '../auth/magicLink/redeemLanding.js';
16
+ import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
17
+
18
+ describe('magic-link core', () => {
19
+ describe('generateRawToken', () => {
20
+ it('prefixes tokens and uses 32 bytes of hex entropy', () => {
21
+ const t = generateRawToken();
22
+ expect(t.startsWith(MAGIC_LINK_TOKEN_PREFIX)).toBe(true);
23
+ const body = t.slice(MAGIC_LINK_TOKEN_PREFIX.length);
24
+ expect(body).toMatch(/^[0-9a-f]{64}$/);
25
+ });
26
+
27
+ it('produces unique tokens', () => {
28
+ const set = new Set(Array.from({ length: 100 }, () => generateRawToken()));
29
+ expect(set.size).toBe(100);
30
+ });
31
+ });
32
+
33
+ describe('hashToken', () => {
34
+ it('is deterministic and base64url-encoded sha256 (43 chars, URL-safe alphabet)', () => {
35
+ const raw = 'mj_ml_abc';
36
+ expect(hashToken(raw)).toBe(hashToken(raw));
37
+ // base64url of 32 bytes = 43 chars, no padding, only [A-Za-z0-9_-]
38
+ expect(hashToken(raw)).toMatch(/^[A-Za-z0-9_-]{43}$/);
39
+ });
40
+
41
+ it('differs for different inputs and never equals the raw token', () => {
42
+ const raw = generateRawToken();
43
+ expect(hashToken(raw)).not.toBe(hashToken(generateRawToken()));
44
+ expect(hashToken(raw)).not.toBe(raw);
45
+ });
46
+ });
47
+
48
+ describe('evaluateInvite', () => {
49
+ const now = Date.UTC(2026, 0, 1);
50
+ const future = new Date(now + 3600_000);
51
+ const past = new Date(now - 3600_000);
52
+
53
+ it('accepts an active, unexpired, unused invite', () => {
54
+ expect(evaluateInvite({ Status: 'Active', ExpiresAt: future, MaxUses: 1, UseCount: 0 }, now)).toEqual({ ok: true });
55
+ });
56
+
57
+ it('rejects revoked invites first', () => {
58
+ expect(evaluateInvite({ Status: 'Revoked', ExpiresAt: future, MaxUses: 1, UseCount: 0 }, now)).toEqual({ ok: false, errorCode: 'revoked' });
59
+ });
60
+
61
+ it('rejects expired invites', () => {
62
+ expect(evaluateInvite({ Status: 'Active', ExpiresAt: past, MaxUses: 1, UseCount: 0 }, now)).toEqual({ ok: false, errorCode: 'expired' });
63
+ });
64
+
65
+ it('rejects consumed invites (by status)', () => {
66
+ expect(evaluateInvite({ Status: 'Consumed', ExpiresAt: future, MaxUses: 1, UseCount: 1 }, now)).toEqual({ ok: false, errorCode: 'consumed' });
67
+ });
68
+
69
+ it('rejects when use count reaches max', () => {
70
+ expect(evaluateInvite({ Status: 'Active', ExpiresAt: future, MaxUses: 3, UseCount: 3 }, now)).toEqual({ ok: false, errorCode: 'consumed' });
71
+ });
72
+
73
+ it('allows multi-use invites that still have uses left', () => {
74
+ expect(evaluateInvite({ Status: 'Active', ExpiresAt: future, MaxUses: 3, UseCount: 2 }, now)).toEqual({ ok: true });
75
+ });
76
+
77
+ it('rejects unknown statuses as invalid', () => {
78
+ expect(evaluateInvite({ Status: 'Pending', ExpiresAt: future, MaxUses: 1, UseCount: 0 }, now)).toEqual({ ok: false, errorCode: 'invalid' });
79
+ });
80
+ });
81
+
82
+ describe('buildSessionClaims', () => {
83
+ it('scopes the token to exactly the given app and role and marks it magic-link', () => {
84
+ const claims = buildSessionClaims({
85
+ issuer: 'http://localhost:4051',
86
+ audience: 'mj-magic-link',
87
+ inviteId: 'INVITE-1',
88
+ email: 'ext@client.com',
89
+ firstName: 'Ext',
90
+ lastName: 'User',
91
+ applicationId: 'APP-1',
92
+ roleName: 'Magic Link Baseline',
93
+ nowSeconds: 1000,
94
+ ttlSeconds: 3600,
95
+ });
96
+ expect(claims.mj_app_id).toBe('APP-1');
97
+ expect(claims.mj_role).toBe('Magic Link Baseline');
98
+ expect(claims.mj_magic_link).toBe(true);
99
+ expect(claims.sub).toBe('magic-link|INVITE-1');
100
+ expect(claims.exp - claims.iat).toBe(3600);
101
+ expect(claims.email).toBe('ext@client.com');
102
+ expect(claims.name).toBe('Ext User');
103
+ });
104
+
105
+ it('carries mj_invited_by when an inviter is supplied (attribution claim)', () => {
106
+ const claims = buildSessionClaims({
107
+ issuer: 'i', audience: 'a', inviteId: 'INVITE-1', email: 'e@x.com',
108
+ applicationId: 'APP-1', roleName: 'Magic Link Baseline',
109
+ invitedByUserId: 'USER-42', nowSeconds: 1000, ttlSeconds: 3600,
110
+ });
111
+ expect(claims.mj_invited_by).toBe('USER-42');
112
+ });
113
+
114
+ it('omits mj_invited_by when no inviter is supplied', () => {
115
+ const claims = buildSessionClaims({
116
+ issuer: 'i', audience: 'a', inviteId: 'INVITE-1', email: 'e@x.com',
117
+ applicationId: 'APP-1', roleName: 'Magic Link Baseline',
118
+ nowSeconds: 1000, ttlSeconds: 3600,
119
+ });
120
+ expect(claims.mj_invited_by).toBeUndefined();
121
+ });
122
+
123
+ it('always emits a single-entry mj_scopes for the current link', () => {
124
+ const claims = buildSessionClaims({
125
+ issuer: 'i', audience: 'a', inviteId: 'INVITE-1', email: 'e@x.com',
126
+ applicationId: 'APP-1', roleName: 'Magic Link Baseline', nowSeconds: 1000, ttlSeconds: 3600,
127
+ });
128
+ expect(claims.mj_scopes).toEqual([{ inviteId: 'INVITE-1', appId: 'APP-1', role: 'Magic Link Baseline', resourceType: undefined, resourceId: undefined }]);
129
+ });
130
+
131
+ it('marks anonymous sessions and carries the per-session id + prior-scope union', () => {
132
+ const claims = buildSessionClaims({
133
+ issuer: 'i', audience: 'a', inviteId: 'INVITE-2', email: 'anon@x',
134
+ applicationId: 'APP-2', roleName: 'Guest', anonymous: true, sessionId: 'SID-9',
135
+ priorScopes: [{ inviteId: 'INVITE-1', appId: 'APP-1', role: 'Guest' }],
136
+ nowSeconds: 1000, ttlSeconds: 3600,
137
+ });
138
+ expect(claims.mj_anon).toBe(true);
139
+ expect(claims.mj_sid).toBe('SID-9');
140
+ // union = prior + this link, both apps present, no accretion duplicate
141
+ expect(claims.mj_scopes?.map((s) => s.appId).sort()).toEqual(['APP-1', 'APP-2']);
142
+ });
143
+
144
+ it('does NOT mark mj_anon for email sessions', () => {
145
+ const claims = buildSessionClaims({
146
+ issuer: 'i', audience: 'a', inviteId: 'INVITE-1', email: 'e@x.com',
147
+ applicationId: 'APP-1', roleName: 'Magic Link Baseline', nowSeconds: 1000, ttlSeconds: 3600,
148
+ });
149
+ expect(claims.mj_anon).toBeUndefined();
150
+ });
151
+ });
152
+
153
+ describe('unionScopes', () => {
154
+ const a = { inviteId: 'I1', appId: 'A1', role: 'R' };
155
+ const b = { inviteId: 'I2', appId: 'A2', role: 'R' };
156
+
157
+ it('appends a new scope entry', () => {
158
+ expect(unionScopes([a], b)).toEqual([a, b]);
159
+ });
160
+
161
+ it('is idempotent by inviteId — re-redeeming the same link never accretes a duplicate', () => {
162
+ expect(unionScopes([a, b], { inviteId: 'I1', appId: 'A1', role: 'R' })).toEqual([a, b]);
163
+ });
164
+
165
+ it('handles an empty/undefined prior union', () => {
166
+ expect(unionScopes(undefined, a)).toEqual([a]);
167
+ expect(unionScopes([], a)).toEqual([a]);
168
+ });
169
+ });
170
+
171
+ describe('buildConsumeInviteSQL', () => {
172
+ const table = '[__mj].[MagicLinkInvite]';
173
+ const sql = buildConsumeInviteSQL(table);
174
+
175
+ it('targets the supplied qualified table', () => {
176
+ expect(sql).toContain(`UPDATE ${table} `);
177
+ });
178
+
179
+ it('increments UseCount', () => {
180
+ expect(sql).toContain('UseCount = UseCount + 1');
181
+ });
182
+
183
+ it('stamps ConsumedAt only the first time (COALESCE preserves an existing value)', () => {
184
+ expect(sql).toContain('ConsumedAt = COALESCE(ConsumedAt, SYSUTCDATETIME())');
185
+ });
186
+
187
+ it('flips Status to Consumed exactly when the last use is taken', () => {
188
+ expect(sql).toContain("Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END");
189
+ });
190
+
191
+ it('returns the affected row via OUTPUT INTO a table var so the caller can detect a win (exactly one row)', () => {
192
+ // Must be OUTPUT ... INTO, not a bare OUTPUT: SQL Server forbids a bare
193
+ // OUTPUT clause on a table with enabled triggers, and CodeGen adds an
194
+ // __mj_UpdatedAt trigger to every MJ table. Regression guard for that bug.
195
+ expect(sql).toContain('OUTPUT INSERTED.ID INTO @consumed');
196
+ expect(sql).not.toMatch(/OUTPUT INSERTED\.ID(?!\s+INTO)/);
197
+ expect(sql).toContain('SELECT ID FROM @consumed');
198
+ });
199
+
200
+ it('guards atomically on Active + not-exhausted + not-expired — this IS the single-use gate', () => {
201
+ const where = sql.slice(sql.indexOf('WHERE'));
202
+ expect(where).toContain("Status = 'Active'");
203
+ expect(where).toContain('UseCount < MaxUses');
204
+ expect(where).toContain('ExpiresAt > SYSUTCDATETIME()');
205
+ });
206
+
207
+ it('binds the invite ID as a parameter, never interpolated (injection-safe)', () => {
208
+ expect(sql).toContain('ID = @p0');
209
+ // The id must not be string-interpolated with quotes around a value.
210
+ expect(sql).not.toMatch(/ID = '/);
211
+ });
212
+
213
+ it('is a self-contained batch: declare table var, update-with-output, select', () => {
214
+ // Intentionally a 3-statement batch (the OUTPUT-INTO requirement). The ID is
215
+ // still parameterized, so the batch carries no interpolated user input.
216
+ expect(sql).toContain('DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER)');
217
+ expect(sql.match(/;/g)?.length).toBe(3);
218
+ expect(sql.trim().endsWith(';')).toBe(true);
219
+ });
220
+
221
+ it('rejects a non-whitelisted table identifier (defense-in-depth against injection)', () => {
222
+ // Only bracket-quoted [schema].[table] of word chars is accepted, even though
223
+ // the caller derives the table from EntityInfo and never from user input.
224
+ expect(() => buildConsumeInviteSQL('__mj.MagicLinkInvite')).toThrow();
225
+ expect(() => buildConsumeInviteSQL('[__mj].[MagicLinkInvite]; DROP TABLE x;--')).toThrow();
226
+ expect(() => buildConsumeInviteSQL('[__mj].[Magic Link]')).toThrow();
227
+ expect(() => buildConsumeInviteSQL(table)).not.toThrow();
228
+ });
229
+ });
230
+
231
+ describe('canIssueInvites', () => {
232
+ it('always allows Owners, regardless of issuer-role config (case/space-insensitive)', () => {
233
+ expect(canIssueInvites('Owner', [], [])).toBe(true);
234
+ expect(canIssueInvites(' owner ', [], [])).toBe(true);
235
+ });
236
+
237
+ it('Owner-only by default: a non-Owner with no configured issuer roles is denied', () => {
238
+ expect(canIssueInvites('User', ['Developer', 'Magic Link Baseline'], [])).toBe(false);
239
+ });
240
+
241
+ it('denies an external user holding the restricted role (the escalation we are blocking)', () => {
242
+ // restricted role is never an issuer role, so this stays false even if someone
243
+ // mistakenly leaves issuerRoleNames empty
244
+ expect(canIssueInvites('User', ['Magic Link Baseline'], [])).toBe(false);
245
+ });
246
+
247
+ it('allows a non-Owner only when one of their roles is a configured issuer role', () => {
248
+ expect(canIssueInvites('User', ['Sales Admin'], ['Sales Admin'])).toBe(true);
249
+ expect(canIssueInvites('User', ['sales admin'], ['Sales Admin'])).toBe(true); // case-insensitive
250
+ expect(canIssueInvites('User', ['Marketing'], ['Sales Admin'])).toBe(false);
251
+ });
252
+
253
+ it('handles null/undefined type and empty role lists safely', () => {
254
+ expect(canIssueInvites(null, [], ['X'])).toBe(false);
255
+ expect(canIssueInvites(undefined, ['X'], ['X'])).toBe(true);
256
+ });
257
+ });
258
+
259
+ describe('isRoleGrantable', () => {
260
+ const restricted = 'Magic Link Baseline';
261
+
262
+ it('always allows the restricted role (case/space-insensitive)', () => {
263
+ expect(isRoleGrantable('Magic Link Baseline', restricted, [])).toBe(true);
264
+ expect(isRoleGrantable(' magic link baseline ', restricted, [])).toBe(true);
265
+ });
266
+
267
+ it('rejects a privileged role by default — blocks roleId=Owner escalation', () => {
268
+ expect(isRoleGrantable('Owner', restricted, [])).toBe(false);
269
+ expect(isRoleGrantable('Administrator', restricted, [])).toBe(false);
270
+ });
271
+
272
+ it('allows additional roles only when explicitly opted in', () => {
273
+ expect(isRoleGrantable('Read Only Guest', restricted, ['Read Only Guest'])).toBe(true);
274
+ expect(isRoleGrantable('read only guest', restricted, ['Read Only Guest'])).toBe(true);
275
+ expect(isRoleGrantable('Owner', restricted, ['Read Only Guest'])).toBe(false);
276
+ });
277
+
278
+ it('rejects empty/null role names', () => {
279
+ expect(isRoleGrantable('', restricted, [])).toBe(false);
280
+ expect(isRoleGrantable(null, restricted, [])).toBe(false);
281
+ });
282
+ });
283
+
284
+ describe('escapeHtml', () => {
285
+ it('escapes the five significant HTML characters', () => {
286
+ expect(escapeHtml(`<>&"'`)).toBe('&lt;&gt;&amp;&quot;&#39;');
287
+ });
288
+
289
+ it('leaves a normal magic-link token untouched', () => {
290
+ const t = generateRawToken();
291
+ expect(escapeHtml(t)).toBe(t);
292
+ });
293
+ });
294
+
295
+ describe('buildRedeemLandingHtml', () => {
296
+ const token = generateRawToken();
297
+ const html = buildRedeemLandingHtml(token, '/magic-link/redeem');
298
+
299
+ it('renders a POST form to the redeem path (GET stays side-effect-free)', () => {
300
+ expect(html).toContain('method="POST"');
301
+ expect(html).toContain('action="/magic-link/redeem"');
302
+ });
303
+
304
+ it('carries the token in a hidden field for the click-to-continue submit', () => {
305
+ expect(html).toContain(`name="token" value="${token}"`);
306
+ });
307
+
308
+ it('requires a human click — it does NOT auto-submit (defeats link scanners)', () => {
309
+ expect(html).not.toContain('.submit()');
310
+ expect(html).not.toContain('onload');
311
+ });
312
+
313
+ it('discourages indexing', () => {
314
+ expect(html).toContain('name="robots"');
315
+ });
316
+
317
+ it('escapes a token containing HTML metacharacters into the form value', () => {
318
+ const evil = buildRedeemLandingHtml('a"><script>x</script>', '/magic-link/redeem');
319
+ expect(evil).not.toContain('<script>x</script>');
320
+ expect(evil).toContain('&lt;script&gt;');
321
+ });
322
+ });
323
+ });
324
+
325
+ describe('MagicLinkKeyManager', () => {
326
+ it('mints an RS256 token verifiable against the published JWKS, with a matching kid', () => {
327
+ const km = MagicLinkKeyManager.Instance;
328
+ km.Initialize(); // ephemeral keypair
329
+
330
+ const claims = buildSessionClaims({
331
+ issuer: 'http://localhost:4051',
332
+ audience: 'mj-magic-link',
333
+ inviteId: 'INVITE-1',
334
+ email: 'ext@client.com',
335
+ applicationId: 'APP-1',
336
+ roleName: 'Magic Link Baseline',
337
+ nowSeconds: Math.floor(Date.now() / 1000),
338
+ ttlSeconds: 3600,
339
+ });
340
+ const token = km.Sign(claims);
341
+
342
+ const jwks = km.GetJWKS();
343
+ expect(jwks.keys).toHaveLength(1);
344
+ const jwk = jwks.keys[0];
345
+ expect(jwk.alg).toBe('RS256');
346
+ expect(jwk.use).toBe('sig');
347
+
348
+ // Token header kid must match the published key id (so jwks-rsa resolves it).
349
+ const header = (jwt.decode(token, { complete: true }) as { header: { kid?: string; alg?: string } }).header;
350
+ expect(header.alg).toBe('RS256');
351
+ expect(header.kid).toBe(jwk.kid);
352
+
353
+ // Full verification against the public key reconstructed from the JWK.
354
+ const publicKey = createPublicKey({ key: jwk as object, format: 'jwk' });
355
+ const decoded = jwt.verify(token, publicKey, {
356
+ algorithms: ['RS256'],
357
+ issuer: 'http://localhost:4051',
358
+ audience: 'mj-magic-link',
359
+ }) as Record<string, unknown>;
360
+ expect(decoded.mj_magic_link).toBe(true);
361
+ expect(decoded.mj_app_id).toBe('APP-1');
362
+ });
363
+
364
+ it('rejects a tampered token', () => {
365
+ const km = MagicLinkKeyManager.Instance;
366
+ km.Initialize();
367
+ const claims = buildSessionClaims({
368
+ issuer: 'http://localhost:4051',
369
+ audience: 'mj-magic-link',
370
+ inviteId: 'INVITE-2',
371
+ email: 'ext@client.com',
372
+ applicationId: 'APP-1',
373
+ roleName: 'Magic Link Baseline',
374
+ nowSeconds: Math.floor(Date.now() / 1000),
375
+ ttlSeconds: 3600,
376
+ });
377
+ const token = km.Sign(claims);
378
+ const publicKey = createPublicKey({ key: km.GetJWKS().keys[0] as object, format: 'jwk' });
379
+
380
+ // Flip a character in the payload segment.
381
+ const parts = token.split('.');
382
+ parts[1] = parts[1].slice(0, -2) + (parts[1].endsWith('A') ? 'BB' : 'AA');
383
+ const tampered = parts.join('.');
384
+
385
+ expect(() => jwt.verify(tampered, publicKey, { algorithms: ['RS256'] })).toThrow();
386
+ });
387
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { IMetadataProvider } from '@memberjunction/core';
3
+ import { redactArg } from '../logging/secretRedactor.js';
4
+
5
+ type EncryptedField = { Name: string };
6
+ type FakeEntity = { ClassName: string; EncryptedFields: EncryptedField[] };
7
+
8
+ function makeProvider(entities: FakeEntity[]): IMetadataProvider {
9
+ return {
10
+ Entities: entities,
11
+ } as unknown as IMetadataProvider;
12
+ }
13
+
14
+ describe('redactArg', () => {
15
+ const credentialEntity: FakeEntity = {
16
+ ClassName: 'MJCredential',
17
+ EncryptedFields: [{ Name: 'Values' }],
18
+ };
19
+ const userEntity: FakeEntity = {
20
+ ClassName: 'User',
21
+ EncryptedFields: [],
22
+ };
23
+
24
+ it('returns "<redacted>" when noLogParameter is set, regardless of other inputs', () => {
25
+ const result = redactArg({
26
+ inputTypeName: 'CreateMJCredentialInput',
27
+ rawValue: { Values: 'FAKE_SECRET_VALUE_DO_NOT_USE' },
28
+ provider: makeProvider([credentialEntity]),
29
+ noLogParameter: true,
30
+ noLogFields: new Set(),
31
+ });
32
+ expect(result).toBe('<redacted>');
33
+ });
34
+
35
+ it('returns "<metadata-not-ready>" when the provider has no entities (bootstrap window)', () => {
36
+ const result = redactArg({
37
+ inputTypeName: 'CreateMJCredentialInput',
38
+ rawValue: { Values: 'FAKE_SECRET_VALUE_DO_NOT_USE' },
39
+ provider: makeProvider([]),
40
+ noLogParameter: false,
41
+ noLogFields: new Set(),
42
+ });
43
+ expect(result).toBe('<metadata-not-ready>');
44
+ });
45
+
46
+ it('fails open (passes through) when input type does not match Create/Update<X>Input regex', () => {
47
+ const result = redactArg({
48
+ inputTypeName: 'SomeCustomArgs',
49
+ rawValue: { accessToken: 'FAKE_TOKEN' },
50
+ provider: makeProvider([credentialEntity]),
51
+ noLogParameter: false,
52
+ noLogFields: new Set(),
53
+ });
54
+ expect(result).toEqual('{"accessToken":"FAKE_TOKEN"}');
55
+ });
56
+
57
+ it('fails open when input type matches regex but no entity is found', () => {
58
+ const result = redactArg({
59
+ inputTypeName: 'CreateUnknownEntityInput',
60
+ rawValue: { Foo: 'bar' },
61
+ provider: makeProvider([credentialEntity]),
62
+ noLogParameter: false,
63
+ noLogFields: new Set(),
64
+ });
65
+ expect(result).toEqual('{"Foo":"bar"}');
66
+ });
67
+
68
+ it('redacts top-level keys matching EntityFieldInfo.Encrypt=true on entity-bound CRUD inputs', () => {
69
+ const result = redactArg({
70
+ inputTypeName: 'CreateMJCredentialInput',
71
+ rawValue: { ID: 'abc-123', Name: 'HubSpot', Values: 'FAKE_SECRET_VALUE_DO_NOT_USE' },
72
+ provider: makeProvider([credentialEntity]),
73
+ noLogParameter: false,
74
+ noLogFields: new Set(),
75
+ });
76
+ expect(result).toEqual({
77
+ ID: 'abc-123',
78
+ Name: 'HubSpot',
79
+ Values: '<redacted>',
80
+ });
81
+ });
82
+
83
+ it('redacts top-level keys in noLogFields even when EncryptedFields does not include them', () => {
84
+ const result = redactArg({
85
+ inputTypeName: 'CreateUserInput',
86
+ rawValue: { Name: 'Alice', SecretToken: 'oops' },
87
+ provider: makeProvider([userEntity]),
88
+ noLogParameter: false,
89
+ noLogFields: new Set(['SecretToken']),
90
+ });
91
+ expect(result).toEqual({
92
+ Name: 'Alice',
93
+ SecretToken: '<redacted>',
94
+ });
95
+ });
96
+
97
+ it('Update<X>Input is also handled (not just Create<X>Input)', () => {
98
+ const result = redactArg({
99
+ inputTypeName: 'UpdateMJCredentialInput',
100
+ rawValue: { ID: 'abc-123', Values: 'NEW_FAKE_SECRET' },
101
+ provider: makeProvider([credentialEntity]),
102
+ noLogParameter: false,
103
+ noLogFields: new Set(),
104
+ });
105
+ expect(result).toEqual({
106
+ ID: 'abc-123',
107
+ Values: '<redacted>',
108
+ });
109
+ });
110
+
111
+ it('Delete<X>Input resolves to its entity and walks keys (PK only — no encrypted values to redact)', () => {
112
+ // Delete is matched by the regex (alongside Create/Update) so the boot audit treats DeleteMJ*
113
+ // resolvers as metadata-bound instead of flooding with false positives. Security is identical:
114
+ // delete inputs carry PK + Options only — no encrypted field, so nothing is redacted, the keys
115
+ // just pass through. See docs/adr/0001-graphql-variables-logging-tiered-by-verbose.md.
116
+ const result = redactArg({
117
+ inputTypeName: 'DeleteMJCredentialInput',
118
+ rawValue: { ID: 'abc-123' },
119
+ provider: makeProvider([credentialEntity]),
120
+ noLogParameter: false,
121
+ noLogFields: new Set(),
122
+ });
123
+ expect(result).toEqual({ ID: 'abc-123' });
124
+ });
125
+
126
+ it('Delete<X>Input never exposes an encrypted value even if one were somehow present', () => {
127
+ // Defense-in-depth: even if a delete input carried an encrypted field name, it must still mask.
128
+ const result = redactArg({
129
+ inputTypeName: 'DeleteMJCredentialInput',
130
+ rawValue: { ID: 'abc-123', Values: 'FAKE_SECRET_VALUE_DO_NOT_USE' },
131
+ provider: makeProvider([credentialEntity]),
132
+ noLogParameter: false,
133
+ noLogFields: new Set(),
134
+ });
135
+ expect(result).toEqual({ ID: 'abc-123', Values: '<redacted>' });
136
+ });
137
+
138
+ it('only walks TOP-level keys — nested objects with encrypted-field names are not recursed', () => {
139
+ const result = redactArg({
140
+ inputTypeName: 'CreateMJCredentialInput',
141
+ rawValue: {
142
+ Name: 'HubSpot',
143
+ Nested: { Values: 'NESTED_FAKE_SECRET' },
144
+ },
145
+ provider: makeProvider([credentialEntity]),
146
+ noLogParameter: false,
147
+ noLogFields: new Set(),
148
+ });
149
+ // Top-level `Values` would be redacted; nested `Values` is not.
150
+ expect((result as Record<string, unknown>).Nested).toEqual('{"Values":"NESTED_FAKE_SECRET"}');
151
+ });
152
+
153
+ it('passes non-object raw values through to shortenForLog', () => {
154
+ const result = redactArg({
155
+ inputTypeName: 'CreateMJCredentialInput',
156
+ rawValue: 'plain-string',
157
+ provider: makeProvider([credentialEntity]),
158
+ noLogParameter: false,
159
+ noLogFields: new Set(),
160
+ });
161
+ expect(result).toBe('plain-string');
162
+ });
163
+ });