@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
- const host = pickHeader(event.headers, 'host');
36
- if (host) {
37
- return host;
38
- }
54
+
39
55
  try {
40
- return new URL(event.rawUrl, 'http://qelos.local').hostname;
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
- return fallbackHost;
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<HandlerEvent, 'rawUrl' | 'rawQuery' | 'queryStringParameters'>,
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 fromRawQuery = event.rawQuery?.trim();
69
- if (fromRawQuery) {
70
- return `${pathname}?${fromRawQuery}`;
71
- }
72
- if (parsed.search) {
73
- return `${pathname}${parsed.search}`;
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
- const fromParams = queryStringFromParameters(event.queryStringParameters);
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.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/sdk": "^4.1.1",
39
- "@qelos/global-types": "^4.1.1"
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"