@memberjunction/server 5.48.0 → 5.49.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.
- package/README.md +9 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +4 -0
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +28 -2
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +122 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +629 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +4 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +1 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +73 -11
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +15 -2
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +113 -11
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/integration/EntityMapLifecycle.d.ts +66 -0
- package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
- package/dist/integration/EntityMapLifecycle.js +189 -0
- package/dist/integration/EntityMapLifecycle.js.map +1 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.js +6 -2
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +81 -25
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts +16 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +26 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +522 -60
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +0 -6
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RSUResolver.d.ts +10 -0
- package/dist/resolvers/RSUResolver.d.ts.map +1 -1
- package/dist/resolvers/RSUResolver.js +28 -0
- package/dist/resolvers/RSUResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +135 -132
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +29 -1
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
- package/dist/resolvers/adhoc-query-helpers.js +42 -0
- package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
- package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +21 -4
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/telephony/media-upgrade-router.d.ts +14 -0
- package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
- package/dist/telephony/media-upgrade-router.js +17 -1
- package/dist/telephony/media-upgrade-router.js.map +1 -1
- package/package.json +88 -88
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
- package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
- package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
- package/src/__tests__/media-upgrade-router.test.ts +43 -0
- package/src/__tests__/unifiedAuth.test.ts +18 -3
- package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
- package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +4 -0
- package/src/config.ts +14 -0
- package/src/context.ts +28 -2
- package/src/generated/generated.ts +446 -1
- package/src/generic/ResolverBase.ts +5 -2
- package/src/generic/RunViewResolver.ts +11 -0
- package/src/index.ts +75 -11
- package/src/integration/CustomColumnPromoter.ts +123 -6
- package/src/integration/EntityMapLifecycle.ts +221 -0
- package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
- package/src/realtimeWidget/WidgetRouter.ts +6 -2
- package/src/resolvers/AdhocQueryResolver.ts +97 -30
- package/src/resolvers/GetDataResolver.ts +30 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +514 -60
- package/src/resolvers/QueryResolver.ts +1 -7
- package/src/resolvers/RSUResolver.ts +28 -0
- package/src/resolvers/RunAIAgentResolver.ts +74 -70
- package/src/resolvers/TransactionGroupResolver.ts +36 -2
- package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
- package/src/resolvers/adhoc-query-helpers.ts +50 -0
- package/src/rest/OAuthCallbackHandler.ts +22 -4
- package/src/telephony/media-upgrade-router.ts +18 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings } from '../integration/EntityMapLifecycle.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* rsuplan "adding and removing tables": removing a table another table relies on must NOT
|
|
6
|
+
* auto-remove the dependent — the broken edge is surfaced as a warning so a consumer can
|
|
7
|
+
* prompt a force-remove (or re-add). These tests pin the pure decision function.
|
|
8
|
+
*/
|
|
9
|
+
describe('ComputeRemovedDependencyWarnings', () => {
|
|
10
|
+
const parents = new Map<string, string[]>([
|
|
11
|
+
['orders', ['Customers']], // Orders depends on Customers
|
|
12
|
+
['orderitems', ['Orders', 'Products']],
|
|
13
|
+
['customers', []],
|
|
14
|
+
['products', []],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
it('warns for each still-active object that depends on a removed object', () => {
|
|
18
|
+
const w = ComputeRemovedDependencyWarnings(['Orders', 'OrderItems', 'Products'], ['Customers'], parents);
|
|
19
|
+
expect(w).toHaveLength(1);
|
|
20
|
+
expect(w[0]).toContain("'Orders'");
|
|
21
|
+
expect(w[0]).toContain("'Customers'");
|
|
22
|
+
expect(w[0]).toContain('force-remove');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('lists every removed parent an active object depends on', () => {
|
|
26
|
+
const w = ComputeRemovedDependencyWarnings(['OrderItems'], ['Orders', 'Products'], parents);
|
|
27
|
+
expect(w).toHaveLength(1);
|
|
28
|
+
expect(w[0]).toContain("'Orders'");
|
|
29
|
+
expect(w[0]).toContain("'Products'");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is case-insensitive on both sides of the edge', () => {
|
|
33
|
+
const w = ComputeRemovedDependencyWarnings(['ORDERS'], ['customers'], parents);
|
|
34
|
+
expect(w).toHaveLength(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns nothing when no active object depends on a removed one (leaf removal is clean)', () => {
|
|
38
|
+
expect(ComputeRemovedDependencyWarnings(['Customers', 'Products'], ['Orders'], parents)).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns nothing when nothing was removed or nothing remains active', () => {
|
|
42
|
+
expect(ComputeRemovedDependencyWarnings(['Orders'], [], parents)).toEqual([]);
|
|
43
|
+
expect(ComputeRemovedDependencyWarnings([], ['Customers'], parents)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('tolerates objects absent from the dependency map (no persisted IO row)', () => {
|
|
47
|
+
expect(ComputeRemovedDependencyWarnings(['UnknownThing'], ['Customers'], parents)).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* rsuplan force-remove: "a consumer may want the user to force remove those too" — the
|
|
53
|
+
* transitive closure of dependents that cascades with a removed object when the caller opts in.
|
|
54
|
+
*/
|
|
55
|
+
describe('ComputeCascadeRemovalSet', () => {
|
|
56
|
+
const parents = new Map<string, string[]>([
|
|
57
|
+
['orders', ['Customers']],
|
|
58
|
+
['orderitems', ['Orders', 'Products']],
|
|
59
|
+
['shipments', ['OrderItems']], // 3 levels deep
|
|
60
|
+
['customers', []],
|
|
61
|
+
['products', []],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
it('cascades direct dependents of a removed object', () => {
|
|
65
|
+
const c = ComputeCascadeRemovalSet(['Orders', 'Products', 'Customers'], ['Products'], parents);
|
|
66
|
+
// Orders depends on Customers (kept) — only OrderItems-style dependents of Products cascade;
|
|
67
|
+
// here Orders does NOT depend on Products, so nothing cascades except true dependents.
|
|
68
|
+
expect(c).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('cascades the FULL transitive closure (removed → dependent → its dependent …)', () => {
|
|
72
|
+
const c = ComputeCascadeRemovalSet(['Orders', 'OrderItems', 'Shipments', 'Products'], ['Customers'], parents);
|
|
73
|
+
// Customers removed → Orders cascades → OrderItems (depends on Orders) → Shipments (depends on OrderItems)
|
|
74
|
+
expect(c).toEqual(['Orders', 'OrderItems', 'Shipments']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is case-insensitive across edges', () => {
|
|
78
|
+
const c = ComputeCascadeRemovalSet(['ORDERS'], ['customers'], parents);
|
|
79
|
+
expect(c).toEqual(['ORDERS']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns nothing when nothing was removed or nothing depends on the removed', () => {
|
|
83
|
+
expect(ComputeCascadeRemovalSet(['Orders'], [], parents)).toEqual([]);
|
|
84
|
+
expect(ComputeCascadeRemovalSet(['Customers', 'Products'], ['Orders'], parents)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('never cascades an object that is itself already removed', () => {
|
|
88
|
+
const c = ComputeCascadeRemovalSet(['Orders', 'OrderItems'], ['Customers', 'Orders'], parents);
|
|
89
|
+
expect(c).toEqual(['OrderItems']); // Orders already in the removed set — not double-listed
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type express from 'express';
|
|
3
|
+
import { OAuthCallbackHandler } from '../rest/OAuthCallbackHandler.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* These tests verify that query-string-derived values interpolated into the OAuth
|
|
7
|
+
* success/error HTML pages are HTML-escaped, preventing reflected XSS (audit bug B6).
|
|
8
|
+
*
|
|
9
|
+
* The page renderers are private, so we reach them through a narrowly-typed view of
|
|
10
|
+
* the instance using the codebase's established `as unknown as` double-assertion pattern.
|
|
11
|
+
*/
|
|
12
|
+
interface PageRenderer {
|
|
13
|
+
handleSuccessPage(req: express.Request, res: express.Response): void;
|
|
14
|
+
handleErrorPage(req: express.Request, res: express.Response): void;
|
|
15
|
+
escapeHtml(value: string): string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeRenderer(): PageRenderer {
|
|
19
|
+
const handler = new OAuthCallbackHandler({ publicUrl: 'https://example.test' });
|
|
20
|
+
return handler as unknown as PageRenderer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal Express request/response doubles that capture the rendered body. */
|
|
24
|
+
function makeReqRes(query: Record<string, string>): {
|
|
25
|
+
req: express.Request;
|
|
26
|
+
res: express.Response;
|
|
27
|
+
getBody: () => string;
|
|
28
|
+
} {
|
|
29
|
+
let body = '';
|
|
30
|
+
const res = {
|
|
31
|
+
status(): express.Response {
|
|
32
|
+
return res as unknown as express.Response;
|
|
33
|
+
},
|
|
34
|
+
send(html: string): express.Response {
|
|
35
|
+
body = html;
|
|
36
|
+
return res as unknown as express.Response;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
req: { query } as unknown as express.Request,
|
|
41
|
+
res: res as unknown as express.Response,
|
|
42
|
+
getBody: () => body
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('OAuthCallbackHandler escapeHtml', () => {
|
|
47
|
+
it('neutralizes <script>, quotes, and ampersands', () => {
|
|
48
|
+
const renderer = makeRenderer();
|
|
49
|
+
const escaped = renderer.escapeHtml(`<script>alert("x")&'y'</script>`);
|
|
50
|
+
expect(escaped).toBe('<script>alert("x")&'y'</script>');
|
|
51
|
+
expect(escaped).not.toContain('<script>');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('escapes & first so no double-encoding occurs', () => {
|
|
55
|
+
const renderer = makeRenderer();
|
|
56
|
+
expect(renderer.escapeHtml('<')).toBe('&lt;');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('OAuthCallbackHandler reflected-XSS protection', () => {
|
|
61
|
+
it('escapes a malicious connectionId on the success page', () => {
|
|
62
|
+
const renderer = makeRenderer();
|
|
63
|
+
const { req, res, getBody } = makeReqRes({ connectionId: '<script>alert(1)</script>' });
|
|
64
|
+
renderer.handleSuccessPage(req, res);
|
|
65
|
+
const body = getBody();
|
|
66
|
+
expect(body).not.toContain('<script>alert(1)</script>');
|
|
67
|
+
expect(body).toContain('<script>alert(1)</script>');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('escapes malicious error code and description on the error page', () => {
|
|
71
|
+
const renderer = makeRenderer();
|
|
72
|
+
const { req, res, getBody } = makeReqRes({
|
|
73
|
+
error: '"><img src=x onerror=alert(1)>',
|
|
74
|
+
error_description: `</div><script>evil()</script>`
|
|
75
|
+
});
|
|
76
|
+
renderer.handleErrorPage(req, res);
|
|
77
|
+
const body = getBody();
|
|
78
|
+
expect(body).not.toContain('<img src=x onerror=alert(1)>');
|
|
79
|
+
expect(body).not.toContain('<script>evil()</script>');
|
|
80
|
+
expect(body).toContain('<img src=x onerror=alert(1)>');
|
|
81
|
+
expect(body).toContain('<script>evil()</script>');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import type { IncomingMessage } from 'node:http';
|
|
3
3
|
import type { Duplex } from 'node:stream';
|
|
4
4
|
import { RealtimeProxyRegistry, REALTIME_PROXY_PATH } from '@memberjunction/ai';
|
|
@@ -68,3 +68,55 @@ describe('RealtimeProxyServer.TryHandleUpgrade', () => {
|
|
|
68
68
|
expect(fake.Destroyed).toBe(true); // already consumed → treated as invalid
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
describe('C3: Origin allowlist (MJ_REALTIME_PROXY_ALLOWED_ORIGINS)', () => {
|
|
73
|
+
const ENV_KEY = 'MJ_REALTIME_PROXY_ALLOWED_ORIGINS';
|
|
74
|
+
let saved: string | undefined;
|
|
75
|
+
let proxy: RealtimeProxyServer;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
proxy = RealtimeProxyServer.Instance;
|
|
79
|
+
saved = process.env[ENV_KEY];
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
if (saved === undefined) delete process.env[ENV_KEY];
|
|
83
|
+
else process.env[ENV_KEY] = saved;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function reqWithOrigin(url: string, origin?: string): IncomingMessage {
|
|
87
|
+
return { url, headers: origin ? { origin } : {} } as unknown as IncomingMessage;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it('no allowlist configured → prior behavior (origin irrelevant, ticket governs)', () => {
|
|
91
|
+
delete process.env[ENV_KEY];
|
|
92
|
+
const fake = new FakeSocket();
|
|
93
|
+
const owned = proxy.TryHandleUpgrade(reqWithOrigin(`${REALTIME_PROXY_PATH}?ticket=nope`, 'https://evil.example'), sock(fake), HEAD);
|
|
94
|
+
expect(owned).toBe(true);
|
|
95
|
+
expect(fake.Written.join('')).toContain('401'); // fell through to the ticket check
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects a FOREIGN browser origin with 403 BEFORE consuming the ticket', () => {
|
|
99
|
+
process.env[ENV_KEY] = 'https://app.example.com, https://portal.example.com';
|
|
100
|
+
const ticket = RealtimeProxyRegistry.Instance.Issue({ UpstreamUrl: 'ws://internal:8000/v1/realtime', TTLSeconds: 60 });
|
|
101
|
+
const fake = new FakeSocket();
|
|
102
|
+
proxy.TryHandleUpgrade(reqWithOrigin(`${REALTIME_PROXY_PATH}?ticket=${ticket.ID}`, 'https://evil.example'), sock(fake), HEAD);
|
|
103
|
+
expect(fake.Written.join('')).toContain('403');
|
|
104
|
+
// The ticket SURVIVED the rejected foreign attempt — the legitimate page can still connect.
|
|
105
|
+
expect(RealtimeProxyRegistry.Instance.Consume(ticket.ID)).not.toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('accepts an allowlisted origin (normalization: case + trailing slash)', () => {
|
|
109
|
+
process.env[ENV_KEY] = 'https://App.Example.com/';
|
|
110
|
+
const fake = new FakeSocket();
|
|
111
|
+
// Unknown ticket → passes the origin gate, fails the ticket check with 401 (NOT 403).
|
|
112
|
+
proxy.TryHandleUpgrade(reqWithOrigin(`${REALTIME_PROXY_PATH}?ticket=nope`, 'https://app.example.com'), sock(fake), HEAD);
|
|
113
|
+
expect(fake.Written.join('')).toContain('401');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('an upgrade WITHOUT an Origin header (non-browser client) passes the gate', () => {
|
|
117
|
+
process.env[ENV_KEY] = 'https://app.example.com';
|
|
118
|
+
const fake = new FakeSocket();
|
|
119
|
+
proxy.TryHandleUpgrade(reqWithOrigin(`${REALTIME_PROXY_PATH}?ticket=nope`), sock(fake), HEAD);
|
|
120
|
+
expect(fake.Written.join('')).toContain('401'); // ticket check, not the origin gate
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { exactTotalFromPage, resolveAdhocTotalRowCount } from '../resolvers/adhoc-query-helpers';
|
|
3
|
+
|
|
4
|
+
describe('exactTotalFromPage', () => {
|
|
5
|
+
it('returns the row count when not paging (maxRows null) — the query was uncapped', () => {
|
|
6
|
+
expect(exactTotalFromPage(0, 47, null)).toBe(47);
|
|
7
|
+
expect(exactTotalFromPage(0, 0, null)).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns startRow + pageLength for a SHORT page (fewer rows than the page size)', () => {
|
|
11
|
+
expect(exactTotalFromPage(0, 47, 100)).toBe(47); // page 1, short → exact total
|
|
12
|
+
expect(exactTotalFromPage(200, 30, 100)).toBe(230); // page 3, short last page
|
|
13
|
+
expect(exactTotalFromPage(100, 0, 100)).toBe(100); // empty page past the end
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns null for a FULL page — a COUNT is required because more rows may exist', () => {
|
|
17
|
+
expect(exactTotalFromPage(0, 100, 100)).toBeNull();
|
|
18
|
+
expect(exactTotalFromPage(500, 100, 100)).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('treats an over-full page defensively as "needs count" (should not occur with OFFSET/FETCH)', () => {
|
|
22
|
+
expect(exactTotalFromPage(0, 150, 100)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('resolveAdhocTotalRowCount', () => {
|
|
27
|
+
it('reads TotalRowCount from the count recordset', () => {
|
|
28
|
+
expect(resolveAdhocTotalRowCount([{ TotalRowCount: 2087 }], 100)).toBe(2087);
|
|
29
|
+
expect(resolveAdhocTotalRowCount([{ TotalRowCount: 0 }], 100)).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('floors a fractional/stringified count', () => {
|
|
33
|
+
expect(resolveAdhocTotalRowCount([{ TotalRowCount: 42.9 }], 0)).toBe(42);
|
|
34
|
+
expect(resolveAdhocTotalRowCount([{ TotalRowCount: '2087' as unknown as number }], 0)).toBe(2087);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('falls back when the count is absent, empty, null, or malformed', () => {
|
|
38
|
+
expect(resolveAdhocTotalRowCount(null, 100)).toBe(100);
|
|
39
|
+
expect(resolveAdhocTotalRowCount(undefined, 100)).toBe(100);
|
|
40
|
+
expect(resolveAdhocTotalRowCount([], 100)).toBe(100);
|
|
41
|
+
expect(resolveAdhocTotalRowCount([{}], 100)).toBe(100);
|
|
42
|
+
expect(resolveAdhocTotalRowCount([{ TotalRowCount: null }], 100)).toBe(100);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('falls back on non-finite or negative counts (never reports a misleading total)', () => {
|
|
46
|
+
expect(resolveAdhocTotalRowCount([{ TotalRowCount: Number.NaN }], 100)).toBe(100);
|
|
47
|
+
expect(resolveAdhocTotalRowCount([{ TotalRowCount: -5 }], 100)).toBe(100);
|
|
48
|
+
expect(resolveAdhocTotalRowCount([{ TotalRowCount: Infinity }], 100)).toBe(100);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// MagicLinkService is the imperative shell — importing it pulls the full
|
|
4
|
+
// data-provider and communication stacks in at module load. Stub those out;
|
|
5
|
+
// this suite only exercises provisioning's console-log behavior, so
|
|
6
|
+
// @memberjunction/core (the logging implementation) stays real.
|
|
7
|
+
vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
|
|
8
|
+
UserCache: { Instance: { Users: [] } },
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('@memberjunction/communication-engine', () => ({
|
|
11
|
+
CommunicationEngine: { Instance: {} },
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('@memberjunction/communication-types', () => ({
|
|
14
|
+
Message: class {},
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('../config.js', () => ({
|
|
17
|
+
configInfo: {},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { Metadata, type IMetadataProvider, type RoleInfo, type UserInfo } from '@memberjunction/core';
|
|
21
|
+
import type { MJMagicLinkInviteEntity } from '@memberjunction/core-entities';
|
|
22
|
+
import type { MagicLinkConfig } from '../config.js';
|
|
23
|
+
import { MagicLinkService } from '../auth/magicLink/MagicLinkService.js';
|
|
24
|
+
|
|
25
|
+
/** Minimal BaseEntity stand-in: NewRecord/Save/GetAll plus writable fields. */
|
|
26
|
+
interface MockEntity {
|
|
27
|
+
NewRecord: () => void;
|
|
28
|
+
Save: () => Promise<boolean>;
|
|
29
|
+
GetAll: () => Record<string, unknown>;
|
|
30
|
+
LatestResult?: { CompleteMessage: string };
|
|
31
|
+
[field: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeMockEntity(id: string): MockEntity {
|
|
35
|
+
const entity: MockEntity = {
|
|
36
|
+
NewRecord: () => {
|
|
37
|
+
entity.ID = id;
|
|
38
|
+
},
|
|
39
|
+
Save: async () => true,
|
|
40
|
+
GetAll: () => ({
|
|
41
|
+
ID: entity.ID,
|
|
42
|
+
Name: entity.Name,
|
|
43
|
+
Email: entity.Email,
|
|
44
|
+
FirstName: entity.FirstName,
|
|
45
|
+
LastName: entity.LastName,
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
return entity;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('MagicLinkService provisioning log policy', () => {
|
|
52
|
+
const originalVerbose = process.env.MJ_VERBOSE;
|
|
53
|
+
let service: MagicLinkService;
|
|
54
|
+
let invite: MJMagicLinkInviteEntity;
|
|
55
|
+
let role: RoleInfo;
|
|
56
|
+
let contextUser: UserInfo;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
const provider = {
|
|
60
|
+
BeginTransaction: async () => undefined,
|
|
61
|
+
CommitTransaction: async () => undefined,
|
|
62
|
+
RollbackTransaction: async () => undefined,
|
|
63
|
+
GetEntityObject: async (entityName: string) => makeMockEntity(`mock-${entityName}`),
|
|
64
|
+
};
|
|
65
|
+
Metadata.Provider = provider as unknown as IMetadataProvider;
|
|
66
|
+
|
|
67
|
+
service = new MagicLinkService('https://mj.example.com', {} as MagicLinkConfig);
|
|
68
|
+
invite = {
|
|
69
|
+
Email: 'guest@example.com',
|
|
70
|
+
RoleID: 'role-1',
|
|
71
|
+
ApplicationID: 'app-1',
|
|
72
|
+
} as unknown as MJMagicLinkInviteEntity;
|
|
73
|
+
role = { ID: 'role-1', Name: 'External Guest' } as RoleInfo;
|
|
74
|
+
contextUser = { ID: 'ctx-user' } as UserInfo;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
Metadata.Provider = null as unknown as IMetadataProvider;
|
|
79
|
+
if (originalVerbose === undefined) {
|
|
80
|
+
delete process.env.MJ_VERBOSE;
|
|
81
|
+
} else {
|
|
82
|
+
process.env.MJ_VERBOSE = originalVerbose;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
async function provisionAndCollectLogs(): Promise<{ success: boolean; provisionLines: string[] }> {
|
|
87
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
88
|
+
const result = await service['createScopedUser'](invite, role, contextUser);
|
|
89
|
+
const provisionLines = logSpy.mock.calls
|
|
90
|
+
.map((call) => String(call[0]))
|
|
91
|
+
.filter((line) => line.includes('[MagicLink] Provisioned'));
|
|
92
|
+
return { success: result.success, provisionLines };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Deliberate policy (do not verbose-gate this like the startup notices in 2ddb865e40):
|
|
96
|
+
// magic-link redemption provisioning a new external account is a security-relevant
|
|
97
|
+
// event and must stay visible in the DEFAULT server log, so operators see the vector
|
|
98
|
+
// being exercised without needing MJ_VERBOSE.
|
|
99
|
+
it('emits the provisioned-user notice on the default (non-verbose) server log', async () => {
|
|
100
|
+
delete process.env.MJ_VERBOSE;
|
|
101
|
+
const { success, provisionLines } = await provisionAndCollectLogs();
|
|
102
|
+
expect(success).toBe(true);
|
|
103
|
+
expect(provisionLines).toHaveLength(1);
|
|
104
|
+
expect(provisionLines[0]).toContain("guest@example.com with role 'External Guest'");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { IsGraphQLWsPath } from '../telephony/media-upgrade-router.js';
|
|
3
|
+
|
|
4
|
+
describe('IsGraphQLWsPath', () => {
|
|
5
|
+
describe('bare-root default (GRAPHQL_ROOT_PATH unset — the stock-install case)', () => {
|
|
6
|
+
it('accepts an exact match on the bare root', () => {
|
|
7
|
+
expect(IsGraphQLWsPath('/', '/')).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('accepts the conventional /graphql suffix as an alias of the bare root', () => {
|
|
11
|
+
// This is the fix: MJExplorer's shipped environment.ts hardcodes
|
|
12
|
+
// GRAPHQL_WS_URI: 'ws://<host>/graphql', which never matched the server's
|
|
13
|
+
// bare-root default before this alias existed — every subscription upgrade
|
|
14
|
+
// was silently rejected (400) on a stock install with no .env overrides.
|
|
15
|
+
expect(IsGraphQLWsPath('/graphql', '/')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects an unrelated path', () => {
|
|
19
|
+
expect(IsGraphQLWsPath('/telephony/twilio/media', '/')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects a path that merely starts with /graphql (no accidental prefix match)', () => {
|
|
23
|
+
expect(IsGraphQLWsPath('/graphql-admin', '/')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('non-default configured root (custom GRAPHQL_ROOT_PATH — unchanged behavior)', () => {
|
|
28
|
+
it('accepts an exact match on the configured root', () => {
|
|
29
|
+
expect(IsGraphQLWsPath('/api/graphql', '/api/graphql')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('does NOT apply the /graphql alias when the configured root is not the bare root', () => {
|
|
33
|
+
// The alias is scoped to the bare-root default only — a deployment that has
|
|
34
|
+
// deliberately set a custom root keeps requiring an exact match, so this
|
|
35
|
+
// fix can't silently widen what a custom-configured server accepts.
|
|
36
|
+
expect(IsGraphQLWsPath('/graphql', '/api/graphql')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects a mismatched custom path', () => {
|
|
40
|
+
expect(IsGraphQLWsPath('/', '/api/graphql')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -258,9 +258,11 @@ describe('createUnifiedAuthMiddleware', () => {
|
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
describe('Authentication failures', () => {
|
|
261
|
-
it('should return 401 when no authorization header is provided', async () => {
|
|
261
|
+
it('should return 401 when no authorization header is provided, quietly (no stack-trace log)', async () => {
|
|
262
262
|
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
263
263
|
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
264
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
265
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
264
266
|
// No auth header → empty bearer token → empty token after strip → "Missing token"
|
|
265
267
|
const req = makeMockReq({ headers: {} });
|
|
266
268
|
const res = makeMockRes();
|
|
@@ -270,7 +272,14 @@ describe('createUnifiedAuthMiddleware', () => {
|
|
|
270
272
|
|
|
271
273
|
expect(next).not.toHaveBeenCalled();
|
|
272
274
|
expect(res._status).toBe(401);
|
|
273
|
-
expect(res._json).toEqual({ error: 'Authentication
|
|
275
|
+
expect(res._json).toEqual({ error: 'Authentication required' });
|
|
276
|
+
// An anonymous/unauthenticated request (health check, CORS-preflight-adjacent probe,
|
|
277
|
+
// a client mid-handshake) is routine — it must NOT log a warning or a stack trace.
|
|
278
|
+
// Same treatment as the existing TokenExpiredError branch.
|
|
279
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
280
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
281
|
+
warnSpy.mockRestore();
|
|
282
|
+
errorSpy.mockRestore();
|
|
274
283
|
});
|
|
275
284
|
|
|
276
285
|
it('should return 401 with JWT_EXPIRED code for expired tokens', async () => {
|
|
@@ -301,10 +310,11 @@ describe('createUnifiedAuthMiddleware', () => {
|
|
|
301
310
|
});
|
|
302
311
|
});
|
|
303
312
|
|
|
304
|
-
it('should return 401 when jwt.decode returns null (corrupt token)', async () => {
|
|
313
|
+
it('should return 401 when jwt.decode returns null (corrupt token), and still log it in full', async () => {
|
|
305
314
|
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
306
315
|
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
307
316
|
mockJwtDecode.mockReturnValue(null);
|
|
317
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
308
318
|
|
|
309
319
|
const req = makeMockReq({
|
|
310
320
|
headers: { authorization: 'Bearer corrupt-token' },
|
|
@@ -317,6 +327,11 @@ describe('createUnifiedAuthMiddleware', () => {
|
|
|
317
327
|
expect(next).not.toHaveBeenCalled();
|
|
318
328
|
expect(res._status).toBe(401);
|
|
319
329
|
expect(res._json).toEqual({ error: 'Authentication failed' });
|
|
330
|
+
// Unlike a bare "no credentials supplied" request, a token that IS present but
|
|
331
|
+
// fails to decode is worth a human's attention — the quiet path above is scoped
|
|
332
|
+
// to the "Missing token" case only, not a blanket suppression of auth logging.
|
|
333
|
+
expect(errorSpy).toHaveBeenCalledWith('Auth error:', expect.any(Error));
|
|
334
|
+
errorSpy.mockRestore();
|
|
320
335
|
});
|
|
321
336
|
|
|
322
337
|
it('should return 401 when verifyUserRecord returns null', async () => {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { BaseSingleton } from '@memberjunction/global';
|
|
13
|
-
import {
|
|
13
|
+
import { LogStatusEx } from '@memberjunction/core';
|
|
14
14
|
import { createPrivateKey, createPublicKey, generateKeyPairSync, createHash, type KeyObject } from 'node:crypto';
|
|
15
15
|
import jwt from 'jsonwebtoken';
|
|
16
16
|
import type { MagicLinkJWTClaims } from './types.js';
|
|
@@ -69,11 +69,17 @@ export class MagicLinkKeyManager extends BaseSingleton<MagicLinkKeyManager> {
|
|
|
69
69
|
} else {
|
|
70
70
|
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
71
71
|
priv = privateKey;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
// Verbose-only: on dev boxes with no configured key this fires on EVERY boot, and the
|
|
73
|
+
// ephemeral fallback is the designed behavior there — not a condition worth a log line in
|
|
74
|
+
// the default startup output. Deployments that care set the key; operators diagnosing
|
|
75
|
+
// magic-link session loss can re-run with verbose logging.
|
|
76
|
+
LogStatusEx({
|
|
77
|
+
message:
|
|
78
|
+
'[MagicLink] No rsaPrivateKey configured — generated an EPHEMERAL RS256 keypair. ' +
|
|
79
|
+
'Outstanding magic-link sessions will be invalidated on restart. Set magicLink.rsaPrivateKey ' +
|
|
80
|
+
'(or MJ_MAGIC_LINK_PRIVATE_KEY) for stable production keys.',
|
|
81
|
+
verboseOnly: true
|
|
82
|
+
});
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
this.privateKeyPem = priv.export({ type: 'pkcs8', format: 'pem' }) as string;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { Router, json, urlencoded, type Request, type Response } from 'express';
|
|
12
12
|
import { rateLimit } from 'express-rate-limit';
|
|
13
|
-
import { LogError, LogStatus, type AuthProviderConfig } from '@memberjunction/core';
|
|
13
|
+
import { LogError, LogStatus, LogStatusEx, type AuthProviderConfig } from '@memberjunction/core';
|
|
14
14
|
import { AuthProviderFactory } from '@memberjunction/auth-providers';
|
|
15
15
|
import { configInfo, type MagicLinkConfig } from '../../config.js';
|
|
16
16
|
import { MagicLinkKeyManager } from './MagicLinkKeys.js';
|
|
@@ -219,7 +219,7 @@ export function registerMagicLinkAuthProvider(publicUrl: string, config: MagicLi
|
|
|
219
219
|
}
|
|
220
220
|
const factory = AuthProviderFactory.Instance;
|
|
221
221
|
factory.register(AuthProviderFactory.createProvider(providerConfig));
|
|
222
|
-
|
|
222
|
+
LogStatusEx({ message: `[MagicLink] Registered auth provider (issuer: ${publicUrl}, jwks: ${providerConfig.jwksUri})`, verboseOnly: true });
|
|
223
223
|
} catch (e) {
|
|
224
224
|
LogError(`[MagicLink] Failed to register auth provider: ${e instanceof Error ? e.message : String(e)}`);
|
|
225
225
|
}
|
|
@@ -549,6 +549,10 @@ export class MagicLinkService {
|
|
|
549
549
|
// resolves this user without a full cache refresh.
|
|
550
550
|
this.pushUserToCache(user, role);
|
|
551
551
|
|
|
552
|
+
// Deliberately NOT verbose-gated, unlike the MagicLink startup notices (2ddb865e40):
|
|
553
|
+
// provisioning a new external account via magic-link redemption is a security-relevant
|
|
554
|
+
// event that belongs in the default server log. At-scale deployments can revisit
|
|
555
|
+
// tapering this if volume ever makes it noise.
|
|
552
556
|
LogStatus(`[MagicLink] Provisioned external user ${email} with role '${role.Name}'.`);
|
|
553
557
|
return { success: true, userId: user.ID, email, firstName, lastName };
|
|
554
558
|
} catch (txErr) {
|
package/src/config.ts
CHANGED
|
@@ -212,6 +212,18 @@ const corsSchema = z.object({
|
|
|
212
212
|
maxAge: z.number().default(86400),
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
const startupSchema = z.object({
|
|
216
|
+
/**
|
|
217
|
+
* Startup mode for engine pre-warm: 'full' (all @RegisterForStartup engines run at boot —
|
|
218
|
+
* the right choice for MJAPI) or 'task' (no engines pre-warm; everything lazy-loads on
|
|
219
|
+
* first touch — the right choice for short-lived CLI/script processes). Because
|
|
220
|
+
* mj.config.cjs is shared by every process in a repo, this can be overridden per
|
|
221
|
+
* invocation via the MJ_STARTUP_MODE env var (highest precedence) or programmatically
|
|
222
|
+
* by each entry point. Unset ⇒ each entry point's own default applies.
|
|
223
|
+
*/
|
|
224
|
+
mode: z.enum(['full', 'task']).optional(),
|
|
225
|
+
});
|
|
226
|
+
|
|
215
227
|
const rateLimitSchema = z.object({
|
|
216
228
|
/** Master switch — when false (default), no rate limiting is applied. */
|
|
217
229
|
enabled: z.boolean().default(false),
|
|
@@ -498,6 +510,7 @@ const configInfoSchema = z.object({
|
|
|
498
510
|
feedbackSettings: feedbackSettingsSchema.optional().default({}),
|
|
499
511
|
cors: corsSchema.optional().default({}),
|
|
500
512
|
rateLimiting: rateLimitSchema.optional().default({}),
|
|
513
|
+
startup: startupSchema.optional().default({}),
|
|
501
514
|
|
|
502
515
|
apiKey: z.string().optional(),
|
|
503
516
|
baseUrl: z.string().default('http://localhost'),
|
|
@@ -556,6 +569,7 @@ export type FeedbackGithubSettingsConfig = z.infer<typeof feedbackGithubSettings
|
|
|
556
569
|
export type FeedbackSettingsConfig = z.infer<typeof feedbackSettingsSchema>;
|
|
557
570
|
export type CorsConfig = z.infer<typeof corsSchema>;
|
|
558
571
|
export type RateLimitConfig = z.infer<typeof rateLimitSchema>;
|
|
572
|
+
export type StartupConfig = z.infer<typeof startupSchema>;
|
|
559
573
|
export type ConfigInfo = z.infer<typeof configInfoSchema>;
|
|
560
574
|
|
|
561
575
|
/**
|
package/src/context.ts
CHANGED
|
@@ -436,7 +436,10 @@ export const getUserPayload = async (
|
|
|
436
436
|
const token = bearerToken.replace('Bearer ', '');
|
|
437
437
|
|
|
438
438
|
if (!token) {
|
|
439
|
-
|
|
439
|
+
// No log here — an anonymous/unauthenticated request is routine (a health check, a
|
|
440
|
+
// CORS preflight-adjacent probe, a client that hasn't finished its auth handshake
|
|
441
|
+
// yet), not exceptional. `createUnifiedAuthMiddleware`'s catch block below is the
|
|
442
|
+
// single place that decides how (and whether) to log this — same as `TokenExpiredError`.
|
|
440
443
|
throw new AuthenticationError('Missing token');
|
|
441
444
|
}
|
|
442
445
|
|
|
@@ -512,12 +515,24 @@ export const getUserPayload = async (
|
|
|
512
515
|
|
|
513
516
|
return { userRecord: sessionUser, email: sessionUser.Email, sessionId };
|
|
514
517
|
} catch (error) {
|
|
515
|
-
|
|
518
|
+
// An anonymous request presenting no credentials at all (a health check, a CORS
|
|
519
|
+
// preflight-adjacent probe, a client mid-handshake) is routine, same as an expired
|
|
520
|
+
// long-lived session — neither is a bug worth a raw console dump here. Both still
|
|
521
|
+
// propagate (audited below, in the "missing token" case) so the single call site
|
|
522
|
+
// that actually decides final logging/response policy — createUnifiedAuthMiddleware's
|
|
523
|
+
// catch block — sees the real error instead of a generic, indistinguishable one.
|
|
524
|
+
const isMissingToken = error instanceof Error && error.message === 'Missing token';
|
|
525
|
+
if (!(error instanceof TokenExpiredError) && !isMissingToken) {
|
|
526
|
+
console.error(error);
|
|
527
|
+
}
|
|
516
528
|
if (error instanceof TokenExpiredError) {
|
|
517
529
|
throw error; // expected for long-lived sessions; not a failure worth auditing
|
|
518
530
|
}
|
|
519
531
|
// Best-effort failure audit (token scanning / brute-force signal). Never blocks auth.
|
|
520
532
|
void auditLoginFailure(bearerToken, error, requestContext, requestDomain);
|
|
533
|
+
if (isMissingToken) {
|
|
534
|
+
throw error; // preserve the original message — see comment above
|
|
535
|
+
}
|
|
521
536
|
throw new AuthenticationError('Unable to authenticate user');
|
|
522
537
|
}
|
|
523
538
|
};
|
|
@@ -632,6 +647,17 @@ export function createUnifiedAuthMiddleware(
|
|
|
632
647
|
});
|
|
633
648
|
return;
|
|
634
649
|
}
|
|
650
|
+
// An unauthenticated request presenting no credentials at all is routine (health
|
|
651
|
+
// checks, CORS preflight-adjacent probes, a client mid-handshake) — same category as
|
|
652
|
+
// TokenExpiredError above, not a bug to surface with a full stack trace. Anything else
|
|
653
|
+
// (a malformed/tampered token, an invalid system API key) is still logged in full below,
|
|
654
|
+
// since those ARE worth a human's attention. Checked by message rather than
|
|
655
|
+
// `instanceof AuthenticationError` — `type-graphql`'s class can resolve to a distinct
|
|
656
|
+
// copy across module/bundler boundaries, where `instanceof` silently never matches.
|
|
657
|
+
if (error instanceof Error && error.message === 'Missing token') {
|
|
658
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
635
661
|
console.error('Auth error:', error);
|
|
636
662
|
res.status(401).json({ error: 'Authentication failed' });
|
|
637
663
|
}
|