@qelos/plugin-netlify-api 4.1.0 → 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.
@@ -0,0 +1,110 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import {
4
+ buildTargetPath,
5
+ isExternalOAuthLocation,
6
+ isUpstreamApiHost,
7
+ publicHostForUpstream,
8
+ } from './proxy-request.ts';
9
+
10
+ describe('buildTargetPath', () => {
11
+ it('uses rawQuery when present', () => {
12
+ assert.equal(
13
+ buildTargetPath({
14
+ path: '/.netlify/functions/qelos-api-proxy',
15
+ rawUrl: '/api/auth/linkedin',
16
+ rawQuery: 'redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback&state=abc',
17
+ }),
18
+ '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback&state=abc',
19
+ );
20
+ });
21
+
22
+ it('falls back to search embedded in rawUrl', () => {
23
+ assert.equal(
24
+ buildTargetPath({
25
+ rawUrl: '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback',
26
+ }),
27
+ '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback',
28
+ );
29
+ });
30
+
31
+ it('falls back to queryStringParameters', () => {
32
+ assert.equal(
33
+ buildTargetPath({
34
+ rawUrl: '/api/auth/linkedin',
35
+ queryStringParameters: {
36
+ redirectUrl: 'https://app.example.com/api/auth/linkedin/callback',
37
+ state: 'abc',
38
+ },
39
+ }),
40
+ '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Flinkedin%2Fcallback&state=abc',
41
+ );
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
+ });
55
+ });
56
+
57
+ describe('publicHostForUpstream', () => {
58
+ it('prefers x-forwarded-host over host', () => {
59
+ assert.equal(
60
+ publicHostForUpstream(
61
+ {
62
+ headers: {
63
+ host: 'admin.example.com',
64
+ 'x-forwarded-host': 'app.example.com',
65
+ },
66
+ rawUrl: '/api/me',
67
+ },
68
+ '159.203.152.168',
69
+ ),
70
+ 'app.example.com',
71
+ );
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
+ });
95
+ });
96
+
97
+ describe('isExternalOAuthLocation', () => {
98
+ it('detects LinkedIn authorization URLs', () => {
99
+ assert.equal(
100
+ isExternalOAuthLocation(
101
+ 'https://www.linkedin.com/oauth/v2/authorization?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback',
102
+ ),
103
+ true,
104
+ );
105
+ });
106
+
107
+ it('rejects same-origin API redirects', () => {
108
+ assert.equal(isExternalOAuthLocation('https://app.example.com/api/me'), false);
109
+ });
110
+ });
@@ -0,0 +1,168 @@
1
+ import type { HandlerEvent } from '@netlify/functions';
2
+
3
+ const OAUTH_PROVIDER_HOSTS = new Set([
4
+ 'linkedin.com',
5
+ 'www.linkedin.com',
6
+ 'accounts.google.com',
7
+ 'www.facebook.com',
8
+ 'github.com',
9
+ ]);
10
+
11
+ export function pickHeader(
12
+ headers: Record<string, string | string[] | undefined>,
13
+ name: string,
14
+ ): string | undefined {
15
+ const want = name.toLowerCase();
16
+ for (const [key, value] of Object.entries(headers)) {
17
+ if (key.toLowerCase() !== want) continue;
18
+ if (value == null) continue;
19
+ const normalized = Array.isArray(value) ? value[0] : value;
20
+ if (typeof normalized === 'string' && normalized.length > 0) {
21
+ return normalized;
22
+ }
23
+ }
24
+ return undefined;
25
+ }
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
+
46
+ export function publicHostForUpstream(
47
+ event: Pick<HandlerEvent, 'headers' | 'rawUrl'>,
48
+ fallbackHost: string,
49
+ ): string {
50
+ const forwarded = pickHeader(event.headers, 'x-forwarded-host');
51
+ if (forwarded) {
52
+ return forwarded.split(',')[0].trim();
53
+ }
54
+
55
+ try {
56
+ const fromRawUrl = new URL(event.rawUrl || '/', 'http://qelos.local').host;
57
+ if (fromRawUrl && !isUpstreamApiHost(fromRawUrl, fallbackHost)) {
58
+ return fromRawUrl;
59
+ }
60
+ } catch {
61
+ // fall through
62
+ }
63
+
64
+ const host = pickHeader(event.headers, 'host');
65
+ if (host && !isUpstreamApiHost(host, fallbackHost)) {
66
+ return host;
67
+ }
68
+
69
+ return fallbackHost;
70
+ }
71
+
72
+ function queryStringFromParameters(
73
+ params: Record<string, string | undefined> | null | undefined,
74
+ ): string {
75
+ if (!params) return '';
76
+ const search = new URLSearchParams();
77
+ for (const [key, value] of Object.entries(params)) {
78
+ if (value != null) search.append(key, value);
79
+ }
80
+ return search.toString();
81
+ }
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
+
107
+ /**
108
+ * Preserve the inbound query string when forwarding to the Qelos API.
109
+ * Netlify may provide `rawQuery`, embed search in `rawUrl` or `path`, or only expose
110
+ * `queryStringParameters` — OAuth social-login flows require `redirectUrl`.
111
+ */
112
+ export function buildTargetPath(
113
+ event: Pick<
114
+ HandlerEvent,
115
+ 'path' | 'rawUrl' | 'rawQuery' | 'queryStringParameters' | 'multiValueQueryStringParameters'
116
+ >,
117
+ ): string {
118
+ const fromPath = queryFromPath(typeof event.path === 'string' ? event.path : '');
119
+ const parsed = new URL(event.rawUrl || '/', 'http://qelos.local');
120
+
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);
133
+
134
+ return query ? `${pathname}?${query}` : pathname;
135
+ }
136
+
137
+ export function isExternalOAuthLocation(location: string): boolean {
138
+ try {
139
+ const host = new URL(location).hostname.toLowerCase();
140
+ if (OAUTH_PROVIDER_HOSTS.has(host)) return true;
141
+ return host.endsWith('.linkedin.com')
142
+ || host.endsWith('.google.com')
143
+ || host.endsWith('.facebook.com')
144
+ || host.endsWith('.github.com');
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ export function buildHtmlRedirectResponse(location: string) {
151
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${escapeHtmlAttribute(location)}"><script>location.replace(${JSON.stringify(location)})</script></head><body>Redirecting…</body></html>`;
152
+ return {
153
+ statusCode: 200,
154
+ headers: {
155
+ 'content-type': 'text/html; charset=utf-8',
156
+ 'cache-control': 'no-store',
157
+ },
158
+ body: html,
159
+ };
160
+ }
161
+
162
+ function escapeHtmlAttribute(value: string): string {
163
+ return value
164
+ .replace(/&/g, '&amp;')
165
+ .replace(/"/g, '&quot;')
166
+ .replace(/</g, '&lt;')
167
+ .replace(/>/g, '&gt;');
168
+ }
@@ -1,6 +1,12 @@
1
1
  import http from "http";
2
2
  import https from "https";
3
3
  import type { Handler } from "@netlify/functions";
4
+ import {
5
+ buildHtmlRedirectResponse,
6
+ buildTargetPath,
7
+ isExternalOAuthLocation,
8
+ publicHostForUpstream,
9
+ } from "./proxy-request";
4
10
 
5
11
  /** Same-origin proxy target; mirrors manual setups that use API_HOST (e.g. ai-words api-proxy). */
6
12
  const raw =
@@ -29,8 +35,7 @@ const ADD_BYPASS_ADMIN_HEADER =
29
35
 
30
36
  export const handler: Handler = (event) =>
31
37
  new Promise((resolve) => {
32
- // Build forwarded headers. Upstream Qelos expects Host = public app domain (e.g. app.auto.qelos.io),
33
- // not the raw API IP — use the visitor-facing host (case-insensitive headers; rawUrl fallback).
38
+ const publicHost = publicHostForUpstream(event, PROXY_HOST);
34
39
  const headers: Record<string, string> = {};
35
40
  for (const [key, val] of Object.entries(event.headers ?? {})) {
36
41
  if (val == null || val === "") continue;
@@ -40,16 +45,14 @@ export const handler: Handler = (event) =>
40
45
  headers[key] = val;
41
46
  }
42
47
  }
43
- headers["host"] = publicHostForUpstream(event);
48
+ headers["host"] = publicHost;
49
+ headers["x-forwarded-host"] = publicHost;
44
50
  if (ADD_BYPASS_ADMIN_HEADER) {
45
51
  headers["x-bypass-admin"] = "true";
46
52
  }
47
- // Lambda buffers the body, so chunked encoding is not applicable
48
53
  delete headers["transfer-encoding"];
49
54
 
50
- const targetPath =
51
- new URL(event.rawUrl).pathname +
52
- (event.rawQuery ? `?${event.rawQuery}` : "");
55
+ const targetPath = buildTargetPath(event);
53
56
 
54
57
  const body = event.body
55
58
  ? Buffer.from(event.body, event.isBase64Encoded ? "base64" : "utf8")
@@ -64,6 +67,18 @@ export const handler: Handler = (event) =>
64
67
  headers,
65
68
  },
66
69
  (proxyRes) => {
70
+ const status = proxyRes.statusCode ?? 200;
71
+ const location = proxyRes.headers.location;
72
+ if (
73
+ status >= 300 &&
74
+ status < 400 &&
75
+ typeof location === "string" &&
76
+ isExternalOAuthLocation(location)
77
+ ) {
78
+ resolve(buildHtmlRedirectResponse(location));
79
+ return;
80
+ }
81
+
67
82
  const chunks: Buffer[] = [];
68
83
  proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk));
69
84
  proxyRes.on("end", () => {
@@ -74,7 +89,7 @@ export const handler: Handler = (event) =>
74
89
  }
75
90
  }
76
91
  resolve({
77
- statusCode: proxyRes.statusCode ?? 200,
92
+ statusCode: status,
78
93
  headers: resHeaders,
79
94
  body: Buffer.concat(chunks).toString("base64"),
80
95
  isBase64Encoded: true,
@@ -92,38 +107,3 @@ export const handler: Handler = (event) =>
92
107
  if (body) proxyReq.write(body);
93
108
  proxyReq.end();
94
109
  });
95
-
96
- function publicHostForUpstream(event: {
97
- headers: Record<string, string | string[] | undefined>;
98
- rawUrl: string;
99
- }): string {
100
- const forwarded = pickHeader(event.headers, "x-forwarded-host");
101
- if (forwarded) {
102
- return forwarded.split(",")[0].trim();
103
- }
104
- const h = pickHeader(event.headers, "host");
105
- if (h) {
106
- return h;
107
- }
108
- try {
109
- return new URL(event.rawUrl).hostname;
110
- } catch {
111
- return PROXY_HOST;
112
- }
113
- }
114
-
115
- function pickHeader(
116
- headers: Record<string, string | string[] | undefined>,
117
- name: string,
118
- ): string | undefined {
119
- const want = name.toLowerCase();
120
- for (const [k, v] of Object.entries(headers)) {
121
- if (k.toLowerCase() !== want) continue;
122
- if (v == null) continue;
123
- const s = Array.isArray(v) ? v[0] : v;
124
- if (typeof s === "string" && s.length > 0) {
125
- return s;
126
- }
127
- }
128
- return undefined;
129
- }
package/index.js CHANGED
@@ -71,9 +71,12 @@ module.exports = {
71
71
  }
72
72
  const destDir = resolveFunctionsDirectory(netlifyConfig);
73
73
  const destFile = path.join(destDir, 'qelos-api-proxy.ts');
74
+ const destHelper = path.join(destDir, 'proxy-request.ts');
74
75
  const srcFile = path.join(__dirname, 'functions', 'qelos-api-proxy.ts');
76
+ const srcHelper = path.join(__dirname, 'functions', 'proxy-request.ts');
75
77
  await fs.mkdir(destDir, { recursive: true });
76
78
  await fs.copyFile(srcFile, destFile);
79
+ await fs.copyFile(srcHelper, destHelper);
77
80
 
78
81
  netlifyConfig.redirects.unshift({
79
82
  from: '/api/*',
@@ -88,6 +91,9 @@ module.exports = {
88
91
  to: `${origin}/api/:splat`,
89
92
  status: 200,
90
93
  force: true,
94
+ headers: {
95
+ 'x-forwarded-host': ':host',
96
+ },
91
97
  };
92
98
  if (bypassAdmin) {
93
99
  redirect.headers = { 'x-bypass-admin': 'true' };
@@ -1,11 +1,9 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
- const {
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
6
4
  readTokensFromEvent,
7
5
  normalizeIntegratorConfig,
8
- } = require('./request-parse.js');
6
+ } from './request-parse.js';
9
7
 
10
8
  test('readTokensFromEvent prefers Authorization bearer over cookie token', () => {
11
9
  const event = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qelos/plugin-netlify-api",
3
- "version": "4.1.0",
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,16 +35,17 @@
35
35
  "node": ">=18"
36
36
  },
37
37
  "dependencies": {
38
- "@qelos/global-types": "^4.1.0",
39
- "@qelos/sdk": "^4.1.0"
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"
43
43
  },
44
44
  "devDependencies": {
45
- "@netlify/functions": "^2.8.2"
45
+ "@netlify/functions": "^2.8.2",
46
+ "tsx": "^4.20.4"
46
47
  },
47
48
  "scripts": {
48
- "test": "node --test integrators/read-tokens.test.cjs"
49
+ "test": "node --import tsx --test \"**/*.test.ts\""
49
50
  }
50
51
  }