@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
@@ -0,0 +1,17 @@
1
+ export * from './types.js';
2
+ export {
3
+ generateRawToken,
4
+ hashToken,
5
+ evaluateInvite,
6
+ buildSessionClaims,
7
+ MAGIC_LINK_TOKEN_PREFIX,
8
+ type InviteEvaluationInput,
9
+ } from './magicLinkCore.js';
10
+ export { MagicLinkKeyManager } from './MagicLinkKeys.js';
11
+ export { MagicLinkService } from './MagicLinkService.js';
12
+ export {
13
+ createMagicLinkHandler,
14
+ registerMagicLinkAuthProvider,
15
+ MAGIC_LINK_MOUNT_PATH,
16
+ MAGIC_LINK_JWKS_PATH,
17
+ } from './MagicLinkRouter.js';
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @fileoverview Pure functional core for magic links — no DB, no email, no MJ
3
+ * runtime imports. Everything here is deterministic given its inputs (modulo
4
+ * crypto randomness) and unit-testable with plain assertions.
5
+ *
6
+ * @module @memberjunction/server/auth/magicLink
7
+ */
8
+
9
+ import { randomBytes, createHash } from 'node:crypto';
10
+ import type { MagicLinkJWTClaims, MagicLinkScopeEntry, RedeemErrorCode } from './types.js';
11
+
12
+ /** Token prefix, mirroring the API-key convention (`mj_sk_`). */
13
+ export const MAGIC_LINK_TOKEN_PREFIX = 'mj_ml_';
14
+
15
+ /** Generates a cryptographically random raw magic-link token. */
16
+ export function generateRawToken(): string {
17
+ return MAGIC_LINK_TOKEN_PREFIX + randomBytes(32).toString('hex');
18
+ }
19
+
20
+ /** Generates an opaque per-session id (anonymous-session forensics correlation). */
21
+ export function generateSessionId(): string {
22
+ return randomBytes(16).toString('base64url');
23
+ }
24
+
25
+ /**
26
+ * SHA-256 hash of a raw token, base64url-encoded — only the hash is ever
27
+ * persisted. base64url (43 chars) is shorter than hex (64 chars), URL-safe, and
28
+ * carries the full 256 bits. Both write (CreateInvite) and read (RedeemInvite)
29
+ * paths call this, so the encoding stays internally consistent.
30
+ */
31
+ export function hashToken(rawToken: string): string {
32
+ return createHash('sha256').update(rawToken).digest('base64url');
33
+ }
34
+
35
+ /** Minimal shape of an invite needed for redemption eligibility. */
36
+ export interface InviteEvaluationInput {
37
+ Status: string;
38
+ ExpiresAt: Date | string;
39
+ MaxUses: number;
40
+ UseCount: number;
41
+ }
42
+
43
+ /** Normalizes a role/name for case- and whitespace-insensitive comparison. */
44
+ function normalizeName(name: string): string {
45
+ return name.trim().toLowerCase();
46
+ }
47
+
48
+ /**
49
+ * Pure authorization check for WHO may issue magic-link invites.
50
+ *
51
+ * Owners are always allowed (Owner is MJ's superuser type). Otherwise the caller
52
+ * must be a member of one of `issuerRoleNames`. An empty `issuerRoleNames` means
53
+ * Owner-only — the secure default. This is what stops any authenticated user
54
+ * (including an external user already holding a restricted magic-link session)
55
+ * from minting invites.
56
+ */
57
+ export function canIssueInvites(
58
+ userType: string | null | undefined,
59
+ userRoleNames: readonly string[],
60
+ issuerRoleNames: readonly string[],
61
+ ): boolean {
62
+ if (normalizeName(userType ?? '') === 'owner') {
63
+ return true;
64
+ }
65
+ const allowed = new Set(issuerRoleNames.map(normalizeName));
66
+ if (allowed.size === 0) {
67
+ return false;
68
+ }
69
+ return userRoleNames.some((r) => allowed.has(normalizeName(r)));
70
+ }
71
+
72
+ /**
73
+ * Pure check for WHAT role an invite may grant. The restricted role is always
74
+ * grantable; any other role must be explicitly listed in `grantableRoleNames`.
75
+ * Applied to every caller (Owners included) so a privileged role can never be
76
+ * attached to an external magic-link user unless the deployment opts in.
77
+ */
78
+ export function isRoleGrantable(
79
+ roleName: string | null | undefined,
80
+ restrictedRoleName: string,
81
+ grantableRoleNames: readonly string[],
82
+ ): boolean {
83
+ const target = normalizeName(roleName ?? '');
84
+ if (!target) {
85
+ return false;
86
+ }
87
+ const allowed = new Set<string>([normalizeName(restrictedRoleName), ...grantableRoleNames.map(normalizeName)]);
88
+ return allowed.has(target);
89
+ }
90
+
91
+ /** Pure redemption-eligibility check. Returns ok + an error code when not. */
92
+ export function evaluateInvite(invite: InviteEvaluationInput, nowMs: number): { ok: boolean; errorCode?: RedeemErrorCode } {
93
+ if (invite.Status === 'Revoked') {
94
+ return { ok: false, errorCode: 'revoked' };
95
+ }
96
+ const expiresMs = new Date(invite.ExpiresAt).getTime();
97
+ if (Number.isFinite(expiresMs) && expiresMs <= nowMs) {
98
+ return { ok: false, errorCode: 'expired' };
99
+ }
100
+ if (invite.Status === 'Consumed' || invite.UseCount >= invite.MaxUses) {
101
+ return { ok: false, errorCode: 'consumed' };
102
+ }
103
+ if (invite.Status !== 'Active') {
104
+ return { ok: false, errorCode: 'invalid' };
105
+ }
106
+ return { ok: true };
107
+ }
108
+
109
+ /**
110
+ * Builds the atomic compare-and-swap UPDATE that consumes one use of an invite.
111
+ *
112
+ * The WHERE clause re-checks every eligibility condition at the DB level, so the
113
+ * increment and the guard are a single atomic operation: concurrent redemptions
114
+ * of a single-use link race on the row and exactly one matches (the matched row
115
+ * is returned via OUTPUT). This is what actually enforces single-use — the
116
+ * JS-side `evaluateInvite` is only a friendly pre-check.
117
+ *
118
+ * The invite ID MUST be bound as parameter `@p0` by the caller — it is never
119
+ * interpolated into the string, so this builder is injection-safe regardless of
120
+ * the ID's contents.
121
+ *
122
+ * OUTPUT goes `INTO` a table variable (not a bare OUTPUT): SQL Server forbids a
123
+ * bare OUTPUT clause on a table that has enabled triggers, and CodeGen adds an
124
+ * `__mj_UpdatedAt` trigger to every MJ table. The trailing SELECT returns exactly
125
+ * the matched row(s) so the caller can detect a win (exactly one row) regardless
126
+ * of trigger behavior.
127
+ *
128
+ * `qualifiedTable` is asserted to be a bracket-quoted `[schema].[table]` (word
129
+ * chars only) before interpolation. The caller derives it from `EntityInfo`
130
+ * (never user input), so this is defense-in-depth: even a future careless caller
131
+ * cannot turn this into an injection vector — a non-conforming table throws.
132
+ *
133
+ * @param qualifiedTable bracket-quoted `[schema].[table]` for MagicLinkInvite
134
+ */
135
+ const QUALIFIED_TABLE_PATTERN = /^\[\w+\]\.\[\w+\]$/;
136
+
137
+ export function buildConsumeInviteSQL(qualifiedTable: string): string {
138
+ if (!QUALIFIED_TABLE_PATTERN.test(qualifiedTable)) {
139
+ throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
140
+ }
141
+ return (
142
+ `DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER); ` +
143
+ `UPDATE ${qualifiedTable} ` +
144
+ `SET UseCount = UseCount + 1, ` +
145
+ `ConsumedAt = COALESCE(ConsumedAt, SYSUTCDATETIME()), ` +
146
+ `Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
147
+ `OUTPUT INSERTED.ID INTO @consumed ` +
148
+ `WHERE ID = @p0 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > SYSUTCDATETIME(); ` +
149
+ `SELECT ID FROM @consumed;`
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Pure scope-union: appends `next` to `prior` unless an entry from the same invite
155
+ * is already present (dedup by inviteId). This is how a session accumulates the union
156
+ * of scopes across multiple redeemed links — carried in the re-minted JWT, never as
157
+ * roles on a shared user, so anonymous sessions can't accrete into a superuser.
158
+ */
159
+ export function unionScopes(prior: readonly MagicLinkScopeEntry[] | undefined, next: MagicLinkScopeEntry): MagicLinkScopeEntry[] {
160
+ const result = [...(prior ?? [])];
161
+ if (!result.some((s) => s.inviteId === next.inviteId)) {
162
+ result.push(next);
163
+ }
164
+ return result;
165
+ }
166
+
167
+ /** Builds the session-token claims (pure). */
168
+ export function buildSessionClaims(args: {
169
+ issuer: string;
170
+ audience: string;
171
+ inviteId: string;
172
+ email: string;
173
+ firstName?: string;
174
+ lastName?: string;
175
+ applicationId: string;
176
+ roleName: string;
177
+ invitedByUserId?: string;
178
+ /** True for anonymous sessions — the server enforces scope from mj_scopes, not roles. */
179
+ anonymous?: boolean;
180
+ /** Opaque per-session id (anonymous forensics correlation). */
181
+ sessionId?: string;
182
+ /** Prior session's scope union to carry forward (multi-link anonymous sessions). */
183
+ priorScopes?: readonly MagicLinkScopeEntry[];
184
+ /** Resource-share/embed scope for this link's entry. */
185
+ resourceType?: string;
186
+ resourceId?: string;
187
+ nowSeconds: number;
188
+ ttlSeconds: number;
189
+ }): MagicLinkJWTClaims {
190
+ const name = [args.firstName, args.lastName].filter(Boolean).join(' ') || undefined;
191
+ const scopeEntry: MagicLinkScopeEntry = {
192
+ inviteId: args.inviteId,
193
+ appId: args.applicationId,
194
+ role: args.roleName,
195
+ resourceType: args.resourceType,
196
+ resourceId: args.resourceId,
197
+ };
198
+ return {
199
+ iss: args.issuer,
200
+ aud: args.audience,
201
+ sub: `magic-link|${args.inviteId}`,
202
+ iat: args.nowSeconds,
203
+ exp: args.nowSeconds + args.ttlSeconds,
204
+ email: args.email,
205
+ given_name: args.firstName,
206
+ family_name: args.lastName,
207
+ name,
208
+ mj_app_id: args.applicationId,
209
+ mj_role: args.roleName,
210
+ mj_invited_by: args.invitedByUserId,
211
+ mj_scopes: unionScopes(args.priorScopes, scopeEntry),
212
+ mj_anon: args.anonymous ? true : undefined,
213
+ mj_sid: args.sessionId,
214
+ mj_magic_link: true,
215
+ };
216
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @fileoverview Pure HTML for the magic-link redemption interstitial. No DB, no
3
+ * MJ runtime imports — deterministic string in, string out.
4
+ * @module @memberjunction/server/auth/magicLink
5
+ */
6
+
7
+ /** Minimal HTML-escape for safe interpolation into attribute/text content. */
8
+ export function escapeHtml(s: string): string {
9
+ return s
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#39;');
15
+ }
16
+
17
+ /**
18
+ * Builds the redemption landing page served by `GET /magic-link/redeem`.
19
+ *
20
+ * GET must be SAFE: link prefetchers, email security scanners, and browser
21
+ * preconnect routinely fetch URLs, and a side-effectful GET would let them burn
22
+ * the single-use token before the human ever clicks. So GET renders this page
23
+ * and the actual redemption happens only when the user submits the form (POST).
24
+ * The "Continue" button is NOT auto-submitted — a bot that merely loads the page
25
+ * (even one that executes JS) does not consume the token; a human gesture does.
26
+ *
27
+ * @param token the raw magic-link token (embedded in a hidden field)
28
+ * @param actionPath the same-origin path the form POSTs to (e.g. `/magic-link/redeem`)
29
+ */
30
+ export function buildRedeemLandingHtml(token: string, actionPath: string): string {
31
+ const t = escapeHtml(token);
32
+ const action = escapeHtml(actionPath);
33
+ // Hardcoded colors are intentional here — this is a standalone server-rendered
34
+ // HTML response, not Angular component CSS, so the design-token rule doesn't apply.
35
+ return `<!doctype html>
36
+ <html lang="en">
37
+ <head>
38
+ <meta charset="utf-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1">
40
+ <meta name="robots" content="noindex, nofollow">
41
+ <title>Sign in</title>
42
+ <style>
43
+ body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background:#f5f5f5; margin:0; display:flex; min-height:100vh; align-items:center; justify-content:center; }
44
+ .card { background:#fff; padding:2.5rem; border-radius:12px; box-shadow:0 1px 4px rgba(0,0,0,.12); max-width:24rem; text-align:center; }
45
+ h1 { font-size:1.25rem; margin:0 0 .5rem; color:#1f2937; }
46
+ p { color:#4b5563; margin:0 0 1.5rem; font-size:.95rem; }
47
+ button { background:#264FAF; color:#fff; border:0; border-radius:8px; padding:.75rem 1.5rem; font-size:1rem; cursor:pointer; width:100%; }
48
+ button:hover { background:#1e3f8c; }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <div class="card">
53
+ <h1>Sign in to continue</h1>
54
+ <p>Click below to securely sign in with your invitation link.</p>
55
+ <form method="POST" action="${action}">
56
+ <input type="hidden" name="token" value="${t}">
57
+ <button type="submit">Continue to sign in</button>
58
+ </form>
59
+ </div>
60
+ </body>
61
+ </html>`;
62
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @fileoverview Types for the MemberJunction magic-link feature.
3
+ * @module @memberjunction/server/auth/magicLink
4
+ */
5
+
6
+ /**
7
+ * One unit of scope a magic-link session holds. The union of these across all links
8
+ * a session has redeemed is the anonymous-session authorization boundary (enforced
9
+ * server-side against the claims, never via roles on the shared Anonymous principal).
10
+ */
11
+ export interface MagicLinkScopeEntry {
12
+ /** The invite that granted this scope entry. */
13
+ inviteId: string;
14
+ /** The Application this entry grants access to. */
15
+ appId: string;
16
+ /** The role name this entry carries (email/app-session links). */
17
+ role?: string;
18
+ /** For resource-share/embed links: the shared resource's type + id. */
19
+ resourceType?: string;
20
+ resourceId?: string;
21
+ }
22
+
23
+ /**
24
+ * Claims carried by a MJ-issued magic-link session JWT.
25
+ * Identity claims (email/given_name/family_name) follow standard OIDC so the
26
+ * existing provider extraction works; the `mj_*` claims describe the scope.
27
+ */
28
+ export interface MagicLinkJWTClaims {
29
+ /** Issuer — MJ's public URL; resolves to the registered `magic-link` auth provider. */
30
+ iss: string;
31
+ /** Audience — the configured magic-link audience (default `mj-magic-link`). */
32
+ aud: string;
33
+ /** Subject — `magic-link|<inviteId>`. */
34
+ sub: string;
35
+ /** Issued-at (epoch seconds). */
36
+ iat: number;
37
+ /** Expiry (epoch seconds). */
38
+ exp: number;
39
+ email: string;
40
+ given_name?: string;
41
+ family_name?: string;
42
+ name?: string;
43
+ /** The single Application this session is scoped to. */
44
+ mj_app_id: string;
45
+ /** The restricted role name assigned to this user (informational; enforcement is server-side via the role's entity permissions). */
46
+ mj_role: string;
47
+ /** UserID of the internal user who issued the invite (attribution/audit). Absent on links whose inviter could not be resolved. */
48
+ mj_invited_by?: string;
49
+ /**
50
+ * Per-session scope UNION (Phase 4+). Each entry is one redeemed link's grant.
51
+ * The server enforces anonymous sessions against THIS set (not the user's role
52
+ * set), which is why two anon visitors sharing the Anonymous principal can hold
53
+ * different scopes without accretion. For email sessions the singular mj_app_id/
54
+ * mj_role remain authoritative; this carries the union when links are stacked.
55
+ */
56
+ mj_scopes?: MagicLinkScopeEntry[];
57
+ /** True when this session resolves to the shared Anonymous principal (claims-based enforcement). */
58
+ mj_anon?: boolean;
59
+ /** Opaque per-session id — correlates one anonymous session's activity across audit rows without a real user. */
60
+ mj_sid?: string;
61
+ /** Marks the session as magic-link so the Explorer can confine the UI. */
62
+ mj_magic_link: true;
63
+ }
64
+
65
+ /** Parameters for creating a magic-link invite (POST /magic-link/create). */
66
+ export interface CreateMagicLinkInviteParams {
67
+ /** Recipient email — becomes the provisioned user's email. */
68
+ email: string;
69
+ /** The Application the invite grants access to. */
70
+ applicationId: string;
71
+ /** Restricted role to assign. Defaults to the configured `restrictedRoleName`. */
72
+ roleId?: string;
73
+ /** Hours until the link expires. Defaults to config `defaultExpiresInHours`. */
74
+ expiresInHours?: number;
75
+ /** Maximum redemptions. Defaults to 1 (single-use). */
76
+ maxUses?: number;
77
+ /** Optional given/family name to seed the provisioned user. */
78
+ firstName?: string;
79
+ lastName?: string;
80
+ }
81
+
82
+ /** Result of creating an invite. */
83
+ export interface CreateMagicLinkInviteResult {
84
+ success: boolean;
85
+ inviteId?: string;
86
+ /** Full redemption URL to share. */
87
+ redemptionUrl?: string;
88
+ /**
89
+ * The raw token, returned ONLY when no email provider is configured (so the
90
+ * caller can deliver the link out of band). Never returned once email send
91
+ * succeeds, and never persisted.
92
+ */
93
+ rawToken?: string;
94
+ /** Whether the invite email was dispatched. */
95
+ emailSent?: boolean;
96
+ /** ISO expiry timestamp. */
97
+ expiresAt?: string;
98
+ error?: string;
99
+ /**
100
+ * Set when the request was rejected for authorization reasons:
101
+ * `forbidden` — caller may not issue invites; `invalid_role` — the requested
102
+ * role is not grantable via magic-link. Drives the HTTP status in the router.
103
+ */
104
+ errorCode?: 'forbidden' | 'invalid_role';
105
+ }
106
+
107
+ /** Why a redemption failed. */
108
+ export type RedeemErrorCode = 'not_found' | 'expired' | 'consumed' | 'revoked' | 'invalid' | 'provisioning_failed' | 'server_error';
109
+
110
+ /**
111
+ * Per-request forensic context captured for the redemption audit trail
112
+ * (`MJ: Magic Link Redemptions`). Sourced from the HTTP request at the router;
113
+ * all fields optional because the API/JSON flow may omit them.
114
+ */
115
+ export interface RedeemAuditContext {
116
+ ipAddress?: string;
117
+ userAgent?: string;
118
+ origin?: string;
119
+ }
120
+
121
+ /** Result of redeeming an invite. */
122
+ export interface RedeemMagicLinkResult {
123
+ success: boolean;
124
+ /** The minted session JWT (RS256). */
125
+ token?: string;
126
+ /** ISO expiry of the session token. */
127
+ expiresAt?: string;
128
+ /** The Application the session is scoped to. */
129
+ applicationId?: string;
130
+ /** The Application's display name (used to deep-link the browser into that app). */
131
+ applicationName?: string;
132
+ /** The Application's URL path slug (preferred for deep-linking; falls back to name). */
133
+ applicationPath?: string;
134
+ email?: string;
135
+ error?: string;
136
+ errorCode?: RedeemErrorCode;
137
+ }
package/src/config.ts CHANGED
@@ -236,8 +236,69 @@ const feedbackSettingsSchema = z.object({
236
236
  github: feedbackGithubSettingsSchema.optional(),
237
237
  });
238
238
 
239
+ const magicLinkSchema = z.object({
240
+ /** Master switch for the magic-link feature. When false, routes are not mounted and the auth provider is not registered. */
241
+ enabled: zodBooleanWithTransforms().default(false),
242
+ /**
243
+ * PEM-encoded RS256 private key used to sign session JWTs. May be raw PEM or
244
+ * base64-encoded PEM. If omitted, an ephemeral keypair is generated at startup
245
+ * (dev only — restarting the server invalidates all outstanding sessions).
246
+ */
247
+ rsaPrivateKey: z.string().optional().default(process.env.MJ_MAGIC_LINK_PRIVATE_KEY || ''),
248
+ /** Hours an unredeemed invite link remains valid (hard expiry). Default 72. */
249
+ defaultExpiresInHours: z.coerce.number().optional().default(72),
250
+ /** Hours the minted session JWT remains valid after redemption. No refresh tokens. Default 8. */
251
+ sessionTokenTtlHours: z.coerce.number().optional().default(8),
252
+ /** Name of the restricted Role assigned to redeeming users when an invite does not specify one. */
253
+ restrictedRoleName: z.string().optional().default('Magic Link Baseline'),
254
+ /**
255
+ * Role names (besides Owner) whose members may issue invites via POST /create.
256
+ * Empty (default) means Owner-only — the secure default. The restricted role
257
+ * must never be listed here, or external users could mint their own invites.
258
+ */
259
+ inviteIssuerRoleNames: z.array(z.string()).optional().default([]),
260
+ /**
261
+ * Role names an invite may grant, IN ADDITION to restrictedRoleName (which is
262
+ * always allowed). A caller-supplied roleId is rejected unless its role name is
263
+ * the restricted role or appears here. This is the guard that stops a caller
264
+ * from attaching a privileged role (e.g. Owner) to an external magic-link user.
265
+ */
266
+ grantableRoleNames: z.array(z.string()).optional().default([]),
267
+ /** Email of the internal user whose context provisions magic-link users (falls back to userHandling.contextUserForNewUserCreation). */
268
+ contextUserForProvisioning: z.string().optional(),
269
+ /**
270
+ * Guard against bolting an external magic-link role/app onto an EXISTING account
271
+ * that is an Owner or already holds non-restricted ("real") roles. One mistyped
272
+ * recipient email is all it takes to hand a colleague an external role otherwise.
273
+ * `block` (default) refuses to provision onto such accounts; `warn` logs loudly
274
+ * and proceeds. Provisioning onto brand-new or already-external accounts is never
275
+ * affected.
276
+ */
277
+ provisioningGuard: z.enum(['block', 'warn']).optional().default('block'),
278
+ /** CommunicationEngine provider name used to deliver invite emails (e.g. 'SendGrid', 'Microsoft Graph'). When unset, emails are not sent and the redemption link is returned to the caller instead. */
279
+ communicationProvider: z.string().optional(),
280
+ /** From address for invite emails. */
281
+ fromAddress: z.string().optional(),
282
+ /** Audience claim for minted JWTs and the auto-registered magic-link auth provider. */
283
+ audience: z.string().optional().default('mj-magic-link'),
284
+ /** Rate-limit window (ms) for the public /redeem and authenticated /create endpoints. Default 60s. */
285
+ rateLimitWindowMs: z.coerce.number().optional().default(60_000),
286
+ /** Max /redeem attempts per IP per window. Default 20. */
287
+ redeemRateLimitMax: z.coerce.number().optional().default(20),
288
+ /** Max /create requests per IP per window. Default 30. */
289
+ createRateLimitMax: z.coerce.number().optional().default(30),
290
+ /**
291
+ * Base URL of the Explorer instance that redeems land in. When set, GET
292
+ * /magic-link/redeem 302-redirects the browser to `${explorerUrl}/#token=<jwt>`
293
+ * (Explorer's magic-link auth provider reads the fragment). When unset, redeem
294
+ * returns the token as JSON. Append `?format=json` to force JSON regardless.
295
+ */
296
+ explorerUrl: z.string().optional(),
297
+ }).passthrough();
298
+
239
299
  const configInfoSchema = z.object({
240
300
  userHandling: userHandlingInfoSchema,
301
+ magicLink: magicLinkSchema.optional().default({}),
241
302
  databaseSettings: databaseSettingsInfoSchema,
242
303
  viewingSystem: viewingSystemInfoSchema.optional(),
243
304
  restApiOptions: restApiOptionsSchema.optional().default({}),
@@ -286,6 +347,7 @@ const configInfoSchema = z.object({
286
347
  });
287
348
 
288
349
  export type UserHandlingInfo = z.infer<typeof userHandlingInfoSchema>;
350
+ export type MagicLinkConfig = z.infer<typeof magicLinkSchema>;
289
351
  export type DatabaseSettingsInfo = z.infer<typeof databaseSettingsInfoSchema>;
290
352
  export type ViewingSystemSettingsInfo = z.infer<typeof viewingSystemInfoSchema>;
291
353
  export type RESTApiOptions = z.infer<typeof restApiOptionsSchema>;