@qelos/plugin-netlify-api 4.1.0 → 4.1.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.
@@ -0,0 +1,74 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import {
4
+ buildTargetPath,
5
+ isExternalOAuthLocation,
6
+ publicHostForUpstream,
7
+ } from './proxy-request.ts';
8
+
9
+ describe('buildTargetPath', () => {
10
+ it('uses rawQuery when present', () => {
11
+ assert.equal(
12
+ buildTargetPath({
13
+ rawUrl: '/api/auth/linkedin',
14
+ rawQuery: 'redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback&state=abc',
15
+ }),
16
+ '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback&state=abc',
17
+ );
18
+ });
19
+
20
+ it('falls back to search embedded in rawUrl', () => {
21
+ assert.equal(
22
+ buildTargetPath({
23
+ rawUrl: '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback',
24
+ }),
25
+ '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fcallback',
26
+ );
27
+ });
28
+
29
+ it('falls back to queryStringParameters', () => {
30
+ assert.equal(
31
+ buildTargetPath({
32
+ rawUrl: '/api/auth/linkedin',
33
+ queryStringParameters: {
34
+ redirectUrl: 'https://app.example.com/api/auth/linkedin/callback',
35
+ state: 'abc',
36
+ },
37
+ }),
38
+ '/api/auth/linkedin?redirectUrl=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Flinkedin%2Fcallback&state=abc',
39
+ );
40
+ });
41
+ });
42
+
43
+ describe('publicHostForUpstream', () => {
44
+ it('prefers x-forwarded-host over host', () => {
45
+ assert.equal(
46
+ publicHostForUpstream(
47
+ {
48
+ headers: {
49
+ host: 'admin.example.com',
50
+ 'x-forwarded-host': 'app.example.com',
51
+ },
52
+ rawUrl: '/api/me',
53
+ },
54
+ '159.203.152.168',
55
+ ),
56
+ 'app.example.com',
57
+ );
58
+ });
59
+ });
60
+
61
+ describe('isExternalOAuthLocation', () => {
62
+ it('detects LinkedIn authorization URLs', () => {
63
+ assert.equal(
64
+ isExternalOAuthLocation(
65
+ 'https://www.linkedin.com/oauth/v2/authorization?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback',
66
+ ),
67
+ true,
68
+ );
69
+ });
70
+
71
+ it('rejects same-origin API redirects', () => {
72
+ assert.equal(isExternalOAuthLocation('https://app.example.com/api/me'), false);
73
+ });
74
+ });
@@ -0,0 +1,111 @@
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 publicHostForUpstream(
28
+ event: Pick<HandlerEvent, 'headers' | 'rawUrl'>,
29
+ fallbackHost: string,
30
+ ): string {
31
+ const forwarded = pickHeader(event.headers, 'x-forwarded-host');
32
+ if (forwarded) {
33
+ return forwarded.split(',')[0].trim();
34
+ }
35
+ const host = pickHeader(event.headers, 'host');
36
+ if (host) {
37
+ return host;
38
+ }
39
+ try {
40
+ return new URL(event.rawUrl, 'http://qelos.local').hostname;
41
+ } catch {
42
+ return fallbackHost;
43
+ }
44
+ }
45
+
46
+ function queryStringFromParameters(
47
+ params: Record<string, string | undefined> | null | undefined,
48
+ ): string {
49
+ if (!params) return '';
50
+ const search = new URLSearchParams();
51
+ for (const [key, value] of Object.entries(params)) {
52
+ if (value != null) search.append(key, value);
53
+ }
54
+ return search.toString();
55
+ }
56
+
57
+ /**
58
+ * Preserve the inbound query string when forwarding to the Qelos API.
59
+ * Netlify may provide `rawQuery`, embed search in `rawUrl`, or only expose
60
+ * `queryStringParameters` — OAuth social-login flows require `redirectUrl`.
61
+ */
62
+ export function buildTargetPath(
63
+ event: Pick<HandlerEvent, 'rawUrl' | 'rawQuery' | 'queryStringParameters'>,
64
+ ): string {
65
+ const parsed = new URL(event.rawUrl || '/', 'http://qelos.local');
66
+ const pathname = parsed.pathname;
67
+
68
+ const fromRawQuery = event.rawQuery?.trim();
69
+ if (fromRawQuery) {
70
+ return `${pathname}?${fromRawQuery}`;
71
+ }
72
+ if (parsed.search) {
73
+ return `${pathname}${parsed.search}`;
74
+ }
75
+
76
+ const fromParams = queryStringFromParameters(event.queryStringParameters);
77
+ return fromParams ? `${pathname}?${fromParams}` : pathname;
78
+ }
79
+
80
+ export function isExternalOAuthLocation(location: string): boolean {
81
+ try {
82
+ const host = new URL(location).hostname.toLowerCase();
83
+ if (OAUTH_PROVIDER_HOSTS.has(host)) return true;
84
+ return host.endsWith('.linkedin.com')
85
+ || host.endsWith('.google.com')
86
+ || host.endsWith('.facebook.com')
87
+ || host.endsWith('.github.com');
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export function buildHtmlRedirectResponse(location: string) {
94
+ 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>`;
95
+ return {
96
+ statusCode: 200,
97
+ headers: {
98
+ 'content-type': 'text/html; charset=utf-8',
99
+ 'cache-control': 'no-store',
100
+ },
101
+ body: html,
102
+ };
103
+ }
104
+
105
+ function escapeHtmlAttribute(value: string): string {
106
+ return value
107
+ .replace(/&/g, '&amp;')
108
+ .replace(/"/g, '&quot;')
109
+ .replace(/</g, '&lt;')
110
+ .replace(/>/g, '&gt;');
111
+ }
@@ -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.1",
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/sdk": "^4.1.1",
39
+ "@qelos/global-types": "^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
  }