@memberjunction/server 5.39.0 → 5.40.1

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 (92) hide show
  1. package/dist/auth/index.js +1 -1
  2. package/dist/auth/index.js.map +1 -1
  3. package/dist/auth/magicLink/MagicLinkKeys.d.ts +58 -0
  4. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -0
  5. package/dist/auth/magicLink/MagicLinkKeys.js +99 -0
  6. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -0
  7. package/dist/auth/magicLink/MagicLinkRouter.d.ts +33 -0
  8. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -0
  9. package/dist/auth/magicLink/MagicLinkRouter.js +184 -0
  10. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -0
  11. package/dist/auth/magicLink/MagicLinkService.d.ts +123 -0
  12. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -0
  13. package/dist/auth/magicLink/MagicLinkService.js +605 -0
  14. package/dist/auth/magicLink/MagicLinkService.js.map +1 -0
  15. package/dist/auth/magicLink/index.d.ts +6 -0
  16. package/dist/auth/magicLink/index.d.ts.map +1 -0
  17. package/dist/auth/magicLink/index.js +6 -0
  18. package/dist/auth/magicLink/index.js.map +1 -0
  19. package/dist/auth/magicLink/magicLinkCore.d.ts +82 -0
  20. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -0
  21. package/dist/auth/magicLink/magicLinkCore.js +164 -0
  22. package/dist/auth/magicLink/magicLinkCore.js.map +1 -0
  23. package/dist/auth/magicLink/redeemLanding.d.ts +22 -0
  24. package/dist/auth/magicLink/redeemLanding.d.ts.map +1 -0
  25. package/dist/auth/magicLink/redeemLanding.js +61 -0
  26. package/dist/auth/magicLink/redeemLanding.js.map +1 -0
  27. package/dist/auth/magicLink/types.d.ts +131 -0
  28. package/dist/auth/magicLink/types.d.ts.map +1 -0
  29. package/dist/auth/magicLink/types.js +6 -0
  30. package/dist/auth/magicLink/types.js.map +1 -0
  31. package/dist/config.d.ts +411 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +60 -0
  34. package/dist/config.js.map +1 -1
  35. package/dist/context.d.ts.map +1 -1
  36. package/dist/context.js +225 -2
  37. package/dist/context.js.map +1 -1
  38. package/dist/generated/generated.d.ts +877 -4
  39. package/dist/generated/generated.d.ts.map +1 -1
  40. package/dist/generated/generated.js +17779 -12750
  41. package/dist/generated/generated.js.map +1 -1
  42. package/dist/generic/ResolverBase.d.ts.map +1 -1
  43. package/dist/generic/ResolverBase.js +35 -7
  44. package/dist/generic/ResolverBase.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +25 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/resolvers/CurrentUserContextResolver.d.ts +9 -3
  50. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -1
  51. package/dist/resolvers/CurrentUserContextResolver.js +19 -5
  52. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -1
  53. package/dist/resolvers/EntityResolver.d.ts.map +1 -1
  54. package/dist/resolvers/EntityResolver.js +13 -2
  55. package/dist/resolvers/EntityResolver.js.map +1 -1
  56. package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts +28 -0
  57. package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts.map +1 -0
  58. package/dist/resolvers/GenerateSeedTaxonomyResolver.js +100 -0
  59. package/dist/resolvers/GenerateSeedTaxonomyResolver.js.map +1 -0
  60. package/dist/resolvers/RunClusterAnalysisResolver.d.ts +74 -0
  61. package/dist/resolvers/RunClusterAnalysisResolver.d.ts.map +1 -0
  62. package/dist/resolvers/RunClusterAnalysisResolver.js +243 -0
  63. package/dist/resolvers/RunClusterAnalysisResolver.js.map +1 -0
  64. package/dist/resolvers/UserResolver.d.ts +16 -2
  65. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  66. package/dist/resolvers/UserResolver.js +45 -2
  67. package/dist/resolvers/UserResolver.js.map +1 -1
  68. package/dist/rest/SignatureWebhookHandler.d.ts +19 -0
  69. package/dist/rest/SignatureWebhookHandler.d.ts.map +1 -0
  70. package/dist/rest/SignatureWebhookHandler.js +86 -0
  71. package/dist/rest/SignatureWebhookHandler.js.map +1 -0
  72. package/package.json +75 -71
  73. package/src/__tests__/magicLink.test.ts +387 -0
  74. package/src/auth/index.ts +2 -2
  75. package/src/auth/magicLink/MagicLinkKeys.ts +122 -0
  76. package/src/auth/magicLink/MagicLinkRouter.ts +209 -0
  77. package/src/auth/magicLink/MagicLinkService.ts +724 -0
  78. package/src/auth/magicLink/index.ts +17 -0
  79. package/src/auth/magicLink/magicLinkCore.ts +216 -0
  80. package/src/auth/magicLink/redeemLanding.ts +62 -0
  81. package/src/auth/magicLink/types.ts +137 -0
  82. package/src/config.ts +62 -0
  83. package/src/context.ts +252 -3
  84. package/src/generated/generated.ts +12302 -8878
  85. package/src/generic/ResolverBase.ts +35 -7
  86. package/src/index.ts +28 -0
  87. package/src/resolvers/CurrentUserContextResolver.ts +21 -5
  88. package/src/resolvers/EntityResolver.ts +17 -5
  89. package/src/resolvers/GenerateSeedTaxonomyResolver.ts +90 -0
  90. package/src/resolvers/RunClusterAnalysisResolver.ts +249 -0
  91. package/src/resolvers/UserResolver.ts +38 -2
  92. package/src/rest/SignatureWebhookHandler.ts +103 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memberjunction/server",
3
- "version": "5.39.0",
3
+ "version": "5.40.1",
4
4
  "description": "MemberJunction: This project provides API access via GraphQL to the common data store.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./src/index.ts",
@@ -27,76 +27,79 @@
27
27
  "@as-integrations/express5": "^1.0.0",
28
28
  "@graphql-tools/schema": "latest",
29
29
  "@graphql-tools/utils": "^11.0.0",
30
- "@memberjunction/actions": "5.39.0",
31
- "@memberjunction/actions-apollo": "5.39.0",
32
- "@memberjunction/actions-base": "5.39.0",
33
- "@memberjunction/actions-bizapps-accounting": "5.39.0",
34
- "@memberjunction/actions-bizapps-crm": "5.39.0",
35
- "@memberjunction/actions-bizapps-formbuilders": "5.39.0",
36
- "@memberjunction/actions-bizapps-lms": "5.39.0",
37
- "@memberjunction/actions-bizapps-social": "5.39.0",
38
- "@memberjunction/ai": "5.39.0",
39
- "@memberjunction/ai-agent-manager": "5.39.0",
40
- "@memberjunction/ai-agent-manager-actions": "5.39.0",
41
- "@memberjunction/ai-agents": "5.39.0",
42
- "@memberjunction/ai-core-plus": "5.39.0",
43
- "@memberjunction/ai-mcp-client": "5.39.0",
44
- "@memberjunction/ai-prompts": "5.39.0",
45
- "@memberjunction/ai-provider-bundle": "5.39.0",
46
- "@memberjunction/ai-vector-sync": "5.39.0",
47
- "@memberjunction/ai-vectordb": "5.39.0",
48
- "@memberjunction/ai-vectors-pinecone": "5.39.0",
49
- "@memberjunction/aiengine": "5.39.0",
50
- "@memberjunction/api-keys": "5.39.0",
51
- "@memberjunction/auth-providers": "5.39.0",
52
- "@memberjunction/codegen-lib": "5.39.0",
53
- "@memberjunction/communication-ms-graph": "5.39.0",
54
- "@memberjunction/communication-sendgrid": "5.39.0",
55
- "@memberjunction/communication-types": "5.39.0",
56
- "@memberjunction/component-registry-client-sdk": "5.39.0",
57
- "@memberjunction/computer-use-engine": "5.39.0",
58
- "@memberjunction/config": "5.39.0",
59
- "@memberjunction/core": "5.39.0",
60
- "@memberjunction/core-actions": "5.39.0",
61
- "@memberjunction/core-entities": "5.39.0",
62
- "@memberjunction/core-entities-server": "5.39.0",
63
- "@memberjunction/data-context": "5.39.0",
64
- "@memberjunction/data-context-server": "5.39.0",
65
- "@memberjunction/doc-utils": "5.39.0",
66
- "@memberjunction/encryption": "5.39.0",
67
- "@memberjunction/entity-communications-base": "5.39.0",
68
- "@memberjunction/entity-communications-server": "5.39.0",
69
- "@memberjunction/external-change-detection": "5.39.0",
70
- "@memberjunction/generic-database-provider": "5.39.0",
71
- "@memberjunction/global": "5.39.0",
72
- "@memberjunction/graphql-dataprovider": "5.39.0",
73
- "@memberjunction/integration-engine": "5.39.0",
74
- "@memberjunction/integration-progress-artifacts": "5.39.0",
75
- "@memberjunction/integration-schema-builder": "5.39.0",
76
- "@memberjunction/interactive-component-types": "5.39.0",
77
- "@memberjunction/lists": "5.39.0",
78
- "@memberjunction/lists-base": "5.39.0",
79
- "@memberjunction/notifications": "5.39.0",
80
- "@memberjunction/postgresql-dataprovider": "5.39.0",
81
- "@memberjunction/queue": "5.39.0",
82
- "@memberjunction/redis-provider": "5.39.0",
83
- "@memberjunction/scheduling-actions": "5.39.0",
84
- "@memberjunction/scheduling-base-types": "5.39.0",
85
- "@memberjunction/scheduling-engine": "5.39.0",
86
- "@memberjunction/scheduling-engine-base": "5.39.0",
87
- "@memberjunction/schema-engine": "5.39.0",
88
- "@memberjunction/search-engine": "5.39.0",
89
- "@memberjunction/server-extensions-core": "5.39.0",
90
- "@memberjunction/skip-types": "5.39.0",
91
- "@memberjunction/sql-dialect": "5.39.0",
92
- "@memberjunction/sqlserver-dataprovider": "5.39.0",
93
- "@memberjunction/storage": "5.39.0",
94
- "@memberjunction/tag-engine": "5.39.0",
95
- "@memberjunction/tag-engine-base": "5.39.0",
96
- "@memberjunction/templates": "5.39.0",
97
- "@memberjunction/testing-engine": "5.39.0",
98
- "@memberjunction/testing-engine-base": "5.39.0",
99
- "@memberjunction/version-history": "5.39.0",
30
+ "@memberjunction/actions": "5.40.1",
31
+ "@memberjunction/actions-apollo": "5.40.1",
32
+ "@memberjunction/actions-base": "5.40.1",
33
+ "@memberjunction/actions-bizapps-accounting": "5.40.1",
34
+ "@memberjunction/actions-bizapps-crm": "5.40.1",
35
+ "@memberjunction/actions-bizapps-formbuilders": "5.40.1",
36
+ "@memberjunction/actions-bizapps-lms": "5.40.1",
37
+ "@memberjunction/actions-bizapps-social": "5.40.1",
38
+ "@memberjunction/ai": "5.40.1",
39
+ "@memberjunction/ai-agent-manager": "5.40.1",
40
+ "@memberjunction/ai-agent-manager-actions": "5.40.1",
41
+ "@memberjunction/ai-agents": "5.40.1",
42
+ "@memberjunction/ai-core-plus": "5.40.1",
43
+ "@memberjunction/ai-mcp-client": "5.40.1",
44
+ "@memberjunction/ai-prompts": "5.40.1",
45
+ "@memberjunction/ai-provider-bundle": "5.40.1",
46
+ "@memberjunction/ai-vector-sync": "5.40.1",
47
+ "@memberjunction/ai-vectordb": "5.40.1",
48
+ "@memberjunction/ai-vectors-pinecone": "5.40.1",
49
+ "@memberjunction/aiengine": "5.40.1",
50
+ "@memberjunction/clustering-engine": "5.40.1",
51
+ "@memberjunction/api-keys": "5.40.1",
52
+ "@memberjunction/auth-providers": "5.40.1",
53
+ "@memberjunction/codegen-lib": "5.40.1",
54
+ "@memberjunction/communication-engine": "5.40.1",
55
+ "@memberjunction/communication-ms-graph": "5.40.1",
56
+ "@memberjunction/communication-sendgrid": "5.40.1",
57
+ "@memberjunction/communication-types": "5.40.1",
58
+ "@memberjunction/component-registry-client-sdk": "5.40.1",
59
+ "@memberjunction/computer-use-engine": "5.40.1",
60
+ "@memberjunction/config": "5.40.1",
61
+ "@memberjunction/core": "5.40.1",
62
+ "@memberjunction/core-actions": "5.40.1",
63
+ "@memberjunction/core-entities": "5.40.1",
64
+ "@memberjunction/core-entities-server": "5.40.1",
65
+ "@memberjunction/data-context": "5.40.1",
66
+ "@memberjunction/esignature": "5.40.1",
67
+ "@memberjunction/data-context-server": "5.40.1",
68
+ "@memberjunction/doc-utils": "5.40.1",
69
+ "@memberjunction/encryption": "5.40.1",
70
+ "@memberjunction/entity-communications-base": "5.40.1",
71
+ "@memberjunction/entity-communications-server": "5.40.1",
72
+ "@memberjunction/external-change-detection": "5.40.1",
73
+ "@memberjunction/generic-database-provider": "5.40.1",
74
+ "@memberjunction/global": "5.40.1",
75
+ "@memberjunction/graphql-dataprovider": "5.40.1",
76
+ "@memberjunction/integration-engine": "5.40.1",
77
+ "@memberjunction/integration-progress-artifacts": "5.40.1",
78
+ "@memberjunction/integration-schema-builder": "5.40.1",
79
+ "@memberjunction/interactive-component-types": "5.40.1",
80
+ "@memberjunction/lists": "5.40.1",
81
+ "@memberjunction/lists-base": "5.40.1",
82
+ "@memberjunction/notifications": "5.40.1",
83
+ "@memberjunction/postgresql-dataprovider": "5.40.1",
84
+ "@memberjunction/queue": "5.40.1",
85
+ "@memberjunction/redis-provider": "5.40.1",
86
+ "@memberjunction/scheduling-actions": "5.40.1",
87
+ "@memberjunction/scheduling-base-types": "5.40.1",
88
+ "@memberjunction/scheduling-engine": "5.40.1",
89
+ "@memberjunction/scheduling-engine-base": "5.40.1",
90
+ "@memberjunction/schema-engine": "5.40.1",
91
+ "@memberjunction/search-engine": "5.40.1",
92
+ "@memberjunction/server-extensions-core": "5.40.1",
93
+ "@memberjunction/skip-types": "5.40.1",
94
+ "@memberjunction/sql-dialect": "5.40.1",
95
+ "@memberjunction/sqlserver-dataprovider": "5.40.1",
96
+ "@memberjunction/storage": "5.40.1",
97
+ "@memberjunction/tag-engine": "5.40.1",
98
+ "@memberjunction/tag-engine-base": "5.40.1",
99
+ "@memberjunction/templates": "5.40.1",
100
+ "@memberjunction/testing-engine": "5.40.1",
101
+ "@memberjunction/testing-engine-base": "5.40.1",
102
+ "@memberjunction/version-history": "5.40.1",
100
103
  "@octokit/auth-app": "^7.1.5",
101
104
  "@octokit/rest": "^21.1.1",
102
105
  "@types/compression": "^1.8.1",
@@ -114,6 +117,7 @@
114
117
  "cosmiconfig": "9.0.0",
115
118
  "dotenv": "^17.2.4",
116
119
  "express": "^5.2.1",
120
+ "express-rate-limit": "^8.2.1",
117
121
  "fast-glob": "^3.3.3",
118
122
  "graphql": "^16.12.0",
119
123
  "graphql-scalars": "^1.25.0",
@@ -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
+ });
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