@qelos/plugin-netlify-api 4.1.1 → 4.1.2
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.
|
@@ -3,6 +3,7 @@ import { describe, it } from 'node:test';
|
|
|
3
3
|
import {
|
|
4
4
|
buildTargetPath,
|
|
5
5
|
isExternalOAuthLocation,
|
|
6
|
+
isUpstreamApiHost,
|
|
6
7
|
publicHostForUpstream,
|
|
7
8
|
} from './proxy-request.ts';
|
|
8
9
|
|
|
@@ -10,6 +11,7 @@ describe('buildTargetPath', () => {
|
|
|
10
11
|
it('uses rawQuery when present', () => {
|
|
11
12
|
assert.equal(
|
|
12
13
|
buildTargetPath({
|
|
14
|
+
path: '/.netlify/functions/qelos-api-proxy',
|
|
13
15
|
rawUrl: '/api/auth/linkedin',
|
|
14
16
|
rawQuery: 'redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback&state=abc',
|
|
15
17
|
}),
|
|
@@ -38,6 +40,18 @@ describe('buildTargetPath', () => {
|
|
|
38
40
|
'/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Flinkedin%2Fcallback&state=abc',
|
|
39
41
|
);
|
|
40
42
|
});
|
|
43
|
+
|
|
44
|
+
it('prefers /api path and query from event.path when rawUrl targets the function', () => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
buildTargetPath({
|
|
47
|
+
path: '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Flinkedin%2Fcallback&state=abc',
|
|
48
|
+
rawUrl: 'https://app.example.com/.netlify/functions/qelos-api-proxy',
|
|
49
|
+
rawQuery: '',
|
|
50
|
+
queryStringParameters: null,
|
|
51
|
+
}),
|
|
52
|
+
'/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Flinkedin%2Fcallback&state=abc',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
41
55
|
});
|
|
42
56
|
|
|
43
57
|
describe('publicHostForUpstream', () => {
|
|
@@ -56,6 +70,28 @@ describe('publicHostForUpstream', () => {
|
|
|
56
70
|
'app.example.com',
|
|
57
71
|
);
|
|
58
72
|
});
|
|
73
|
+
|
|
74
|
+
it('prefers rawUrl host over upstream API host header', () => {
|
|
75
|
+
assert.equal(
|
|
76
|
+
publicHostForUpstream(
|
|
77
|
+
{
|
|
78
|
+
headers: {
|
|
79
|
+
host: 'admin.example.com',
|
|
80
|
+
},
|
|
81
|
+
rawUrl: 'https://app.example.com/api/auth/linkedin',
|
|
82
|
+
},
|
|
83
|
+
'admin.example.com',
|
|
84
|
+
),
|
|
85
|
+
'app.example.com',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('isUpstreamApiHost', () => {
|
|
91
|
+
it('treats configured upstream host as internal', () => {
|
|
92
|
+
assert.equal(isUpstreamApiHost('admin.example.com', 'https://admin.example.com'), true);
|
|
93
|
+
assert.equal(isUpstreamApiHost('app.example.com', 'https://admin.example.com'), false);
|
|
94
|
+
});
|
|
59
95
|
});
|
|
60
96
|
|
|
61
97
|
describe('isExternalOAuthLocation', () => {
|
|
@@ -24,6 +24,25 @@ export function pickHeader(
|
|
|
24
24
|
return undefined;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function isUpstreamApiHost(host: string, upstreamFallback: string): boolean {
|
|
28
|
+
const normalized = host.split(',')[0].trim().split(':')[0].toLowerCase();
|
|
29
|
+
if (!normalized) return true;
|
|
30
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(normalized)) return true;
|
|
31
|
+
if (normalized.includes('auth-service') || normalized.includes('gateway')) return true;
|
|
32
|
+
|
|
33
|
+
const upstream = upstreamFallback.trim();
|
|
34
|
+
if (!upstream) return false;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const upstreamHost = (
|
|
38
|
+
upstream.includes('://') ? new URL(upstream).hostname : upstream.split('/')[0].split(':')[0]
|
|
39
|
+
).toLowerCase();
|
|
40
|
+
return normalized === upstreamHost;
|
|
41
|
+
} catch {
|
|
42
|
+
return normalized === upstream.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
export function publicHostForUpstream(
|
|
28
47
|
event: Pick<HandlerEvent, 'headers' | 'rawUrl'>,
|
|
29
48
|
fallbackHost: string,
|
|
@@ -32,15 +51,22 @@ export function publicHostForUpstream(
|
|
|
32
51
|
if (forwarded) {
|
|
33
52
|
return forwarded.split(',')[0].trim();
|
|
34
53
|
}
|
|
35
|
-
|
|
36
|
-
if (host) {
|
|
37
|
-
return host;
|
|
38
|
-
}
|
|
54
|
+
|
|
39
55
|
try {
|
|
40
|
-
|
|
56
|
+
const fromRawUrl = new URL(event.rawUrl || '/', 'http://qelos.local').host;
|
|
57
|
+
if (fromRawUrl && !isUpstreamApiHost(fromRawUrl, fallbackHost)) {
|
|
58
|
+
return fromRawUrl;
|
|
59
|
+
}
|
|
41
60
|
} catch {
|
|
42
|
-
|
|
61
|
+
// fall through
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const host = pickHeader(event.headers, 'host');
|
|
65
|
+
if (host && !isUpstreamApiHost(host, fallbackHost)) {
|
|
66
|
+
return host;
|
|
43
67
|
}
|
|
68
|
+
|
|
69
|
+
return fallbackHost;
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
function queryStringFromParameters(
|
|
@@ -54,27 +80,58 @@ function queryStringFromParameters(
|
|
|
54
80
|
return search.toString();
|
|
55
81
|
}
|
|
56
82
|
|
|
83
|
+
function queryStringFromMultiValue(
|
|
84
|
+
params: Record<string, string[] | undefined> | null | undefined,
|
|
85
|
+
): string {
|
|
86
|
+
if (!params) return '';
|
|
87
|
+
const search = new URLSearchParams();
|
|
88
|
+
for (const [key, values] of Object.entries(params)) {
|
|
89
|
+
if (!values) continue;
|
|
90
|
+
for (const value of values) {
|
|
91
|
+
if (value != null) search.append(key, value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return search.toString();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function queryFromPath(path: string | undefined): { pathname: string; query: string } {
|
|
98
|
+
if (!path) return { pathname: '', query: '' };
|
|
99
|
+
const qIndex = path.indexOf('?');
|
|
100
|
+
if (qIndex === -1) return { pathname: path, query: '' };
|
|
101
|
+
return {
|
|
102
|
+
pathname: path.slice(0, qIndex),
|
|
103
|
+
query: path.slice(qIndex + 1),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
57
107
|
/**
|
|
58
108
|
* Preserve the inbound query string when forwarding to the Qelos API.
|
|
59
|
-
* Netlify may provide `rawQuery`, embed search in `rawUrl`, or only expose
|
|
109
|
+
* Netlify may provide `rawQuery`, embed search in `rawUrl` or `path`, or only expose
|
|
60
110
|
* `queryStringParameters` — OAuth social-login flows require `redirectUrl`.
|
|
61
111
|
*/
|
|
62
112
|
export function buildTargetPath(
|
|
63
|
-
event: Pick<
|
|
113
|
+
event: Pick<
|
|
114
|
+
HandlerEvent,
|
|
115
|
+
'path' | 'rawUrl' | 'rawQuery' | 'queryStringParameters' | 'multiValueQueryStringParameters'
|
|
116
|
+
>,
|
|
64
117
|
): string {
|
|
118
|
+
const fromPath = queryFromPath(typeof event.path === 'string' ? event.path : '');
|
|
65
119
|
const parsed = new URL(event.rawUrl || '/', 'http://qelos.local');
|
|
66
|
-
const pathname = parsed.pathname;
|
|
67
120
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
121
|
+
const pathname =
|
|
122
|
+
(fromPath.pathname.startsWith('/api') ? fromPath.pathname : '') ||
|
|
123
|
+
(parsed.pathname.startsWith('/api') ? parsed.pathname : '') ||
|
|
124
|
+
fromPath.pathname ||
|
|
125
|
+
parsed.pathname;
|
|
126
|
+
|
|
127
|
+
const query =
|
|
128
|
+
event.rawQuery?.trim() ||
|
|
129
|
+
fromPath.query ||
|
|
130
|
+
(parsed.search ? parsed.search.slice(1) : '') ||
|
|
131
|
+
queryStringFromParameters(event.queryStringParameters) ||
|
|
132
|
+
queryStringFromMultiValue(event.multiValueQueryStringParameters);
|
|
75
133
|
|
|
76
|
-
|
|
77
|
-
return fromParams ? `${pathname}?${fromParams}` : pathname;
|
|
134
|
+
return query ? `${pathname}?${query}` : pathname;
|
|
78
135
|
}
|
|
79
136
|
|
|
80
137
|
export function isExternalOAuthLocation(location: string): boolean {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qelos/plugin-netlify-api",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.2",
|
|
4
4
|
"description": "Netlify build plugin: proxy /api/* to Qelos API and inject config",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"node": ">=18"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@qelos/
|
|
39
|
-
"@qelos/
|
|
38
|
+
"@qelos/global-types": "^4.1.1",
|
|
39
|
+
"@qelos/sdk": "^4.1.1"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@netlify/functions": ">=2.0.0"
|