@qelos/integrator-nuxt 4.0.0 → 4.1.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 +112 -59
- package/dist/cookies.d.ts +19 -0
- package/dist/cookies.js +39 -0
- package/dist/cookies.js.map +1 -0
- package/dist/middleware.d.ts +4 -8
- package/dist/middleware.js +58 -54
- package/dist/middleware.js.map +1 -1
- package/dist/module.d.ts +6 -1
- package/dist/module.js +21 -1
- package/dist/module.js.map +1 -1
- package/dist/proxy-target.d.ts +17 -0
- package/dist/proxy-target.js +26 -0
- package/dist/proxy-target.js.map +1 -0
- package/dist/runtime/api-proxy.d.ts +2 -0
- package/dist/runtime/api-proxy.js +27 -0
- package/dist/runtime/api-proxy.js.map +1 -0
- package/dist/sdk-factory.d.ts +3 -10
- package/dist/sdk-factory.js +14 -70
- package/dist/sdk-factory.js.map +1 -1
- package/dist/types.d.ts +9 -29
- package/package.json +2 -2
- package/src/cookies.test.ts +112 -0
- package/src/cookies.ts +49 -0
- package/src/middleware.ts +65 -78
- package/src/module.ts +28 -5
- package/src/proxy-target.test.ts +110 -0
- package/src/proxy-target.ts +30 -0
- package/src/runtime/api-proxy.ts +36 -0
- package/src/sdk-factory.ts +17 -89
- package/src/types.ts +9 -33
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolveQelosProxyTarget } from './proxy-target';
|
|
4
|
+
import type { QelosNuxtRuntimeConfig } from './types';
|
|
5
|
+
|
|
6
|
+
const PROXY_ENV_KEYS = [
|
|
7
|
+
'NUXT_QELOS_PROXY_TARGET',
|
|
8
|
+
'QELOS_IP',
|
|
9
|
+
'QELOS_API_IP',
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
function clearProxyEnv(): void {
|
|
13
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
14
|
+
delete process.env[key];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('resolveQelosProxyTarget', () => {
|
|
19
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
23
|
+
savedEnv[key] = process.env[key];
|
|
24
|
+
}
|
|
25
|
+
clearProxyEnv();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
30
|
+
if (savedEnv[key] === undefined) {
|
|
31
|
+
delete process.env[key];
|
|
32
|
+
} else {
|
|
33
|
+
process.env[key] = savedEnv[key];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('uses NUXT_QELOS_PROXY_TARGET when set, even if other env vars and appUrl exist', () => {
|
|
39
|
+
process.env.NUXT_QELOS_PROXY_TARGET = 'https://from-nuxt-env.example';
|
|
40
|
+
process.env.QELOS_IP = 'https://from-qelos-ip.example';
|
|
41
|
+
process.env.QELOS_API_IP = 'https://from-qelos-api-ip.example';
|
|
42
|
+
const config: QelosNuxtRuntimeConfig = {
|
|
43
|
+
appUrl: 'https://from-app-url.example',
|
|
44
|
+
};
|
|
45
|
+
assert.equal(
|
|
46
|
+
resolveQelosProxyTarget(config),
|
|
47
|
+
'https://from-nuxt-env.example',
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('falls back to QELOS_IP when NUXT_QELOS_PROXY_TARGET is unset', () => {
|
|
52
|
+
process.env.QELOS_IP = 'https://from-qelos-ip.example';
|
|
53
|
+
process.env.QELOS_API_IP = 'https://from-qelos-api-ip.example';
|
|
54
|
+
const config: QelosNuxtRuntimeConfig = {
|
|
55
|
+
appUrl: 'https://from-app-url.example',
|
|
56
|
+
};
|
|
57
|
+
assert.equal(
|
|
58
|
+
resolveQelosProxyTarget(config),
|
|
59
|
+
'https://from-qelos-ip.example',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('falls back to QELOS_API_IP when higher-priority env vars are unset', () => {
|
|
64
|
+
process.env.QELOS_API_IP = 'https://from-qelos-api-ip.example';
|
|
65
|
+
const config: QelosNuxtRuntimeConfig = {
|
|
66
|
+
appUrl: 'https://from-app-url.example',
|
|
67
|
+
};
|
|
68
|
+
assert.equal(
|
|
69
|
+
resolveQelosProxyTarget(config),
|
|
70
|
+
'https://from-qelos-api-ip.example',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('falls back to config.appUrl when no env var is set', () => {
|
|
75
|
+
const config: QelosNuxtRuntimeConfig = {
|
|
76
|
+
appUrl: 'https://from-app-url.example',
|
|
77
|
+
};
|
|
78
|
+
assert.equal(
|
|
79
|
+
resolveQelosProxyTarget(config),
|
|
80
|
+
'https://from-app-url.example',
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns undefined when nothing is configured', () => {
|
|
85
|
+
const config: QelosNuxtRuntimeConfig = { appUrl: '' };
|
|
86
|
+
assert.equal(resolveQelosProxyTarget(config), undefined);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('treats whitespace-only values as unset', () => {
|
|
90
|
+
process.env.NUXT_QELOS_PROXY_TARGET = ' ';
|
|
91
|
+
process.env.QELOS_IP = '\t';
|
|
92
|
+
const config: QelosNuxtRuntimeConfig = {
|
|
93
|
+
appUrl: 'https://from-app-url.example',
|
|
94
|
+
};
|
|
95
|
+
assert.equal(
|
|
96
|
+
resolveQelosProxyTarget(config),
|
|
97
|
+
'https://from-app-url.example',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('trims surrounding whitespace from resolved values', () => {
|
|
102
|
+
const config: QelosNuxtRuntimeConfig = {
|
|
103
|
+
appUrl: ' https://trimmed.example ',
|
|
104
|
+
};
|
|
105
|
+
assert.equal(
|
|
106
|
+
resolveQelosProxyTarget(config),
|
|
107
|
+
'https://trimmed.example',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { QelosNuxtRuntimeConfig } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the origin to which `/api/**` requests are proxied. The managed
|
|
5
|
+
* Qelos app URL (`config.appUrl`) is the proxy target — env vars are only
|
|
6
|
+
* dev-time overrides when the configured `appUrl` is not reachable from
|
|
7
|
+
* the local host.
|
|
8
|
+
*
|
|
9
|
+
* Priority (highest first):
|
|
10
|
+
* 1. `process.env.NUXT_QELOS_PROXY_TARGET`
|
|
11
|
+
* 2. `process.env.QELOS_IP`
|
|
12
|
+
* 3. `process.env.QELOS_API_IP`
|
|
13
|
+
* 4. `config.appUrl`
|
|
14
|
+
*
|
|
15
|
+
* Empty / whitespace-only values are skipped, and `undefined` is returned when
|
|
16
|
+
* nothing is configured so the caller can surface a clear error.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveQelosProxyTarget(
|
|
19
|
+
config: QelosNuxtRuntimeConfig,
|
|
20
|
+
): string | undefined {
|
|
21
|
+
const env = process.env;
|
|
22
|
+
const fromEnv =
|
|
23
|
+
env.NUXT_QELOS_PROXY_TARGET?.trim() ||
|
|
24
|
+
env.QELOS_IP?.trim() ||
|
|
25
|
+
env.QELOS_API_IP?.trim();
|
|
26
|
+
if (fromEnv) return fromEnv;
|
|
27
|
+
|
|
28
|
+
const fromAppUrl = config.appUrl?.trim();
|
|
29
|
+
return fromAppUrl || undefined;
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// This file is registered as a Nuxt server handler by `module.ts`. It is
|
|
2
|
+
// bundled by Nitro, so `#imports` resolves at the consuming-app build time
|
|
3
|
+
// (not at this package's `tsc` build).
|
|
4
|
+
// @ts-ignore - resolved by Nitro
|
|
5
|
+
import { useRuntimeConfig } from '#imports';
|
|
6
|
+
import {
|
|
7
|
+
createError,
|
|
8
|
+
defineEventHandler,
|
|
9
|
+
getRequestHeader,
|
|
10
|
+
proxyRequest,
|
|
11
|
+
} from 'h3';
|
|
12
|
+
// @ts-ignore - resolved by Nitro (transitive dep of Nuxt)
|
|
13
|
+
import { joinURL } from 'ufo';
|
|
14
|
+
import { resolveQelosProxyTarget } from '../proxy-target';
|
|
15
|
+
import type { QelosNuxtRuntimeConfig } from '../types';
|
|
16
|
+
|
|
17
|
+
export default defineEventHandler((event) => {
|
|
18
|
+
const config = (useRuntimeConfig().qelos || {}) as QelosNuxtRuntimeConfig;
|
|
19
|
+
const base = resolveQelosProxyTarget(config);
|
|
20
|
+
if (!base) {
|
|
21
|
+
throw createError({
|
|
22
|
+
statusCode: 503,
|
|
23
|
+
statusMessage:
|
|
24
|
+
'[@qelos/integrator-nuxt] Qelos API proxy is not configured. Set qelos.appUrl (or NUXT_QELOS_PROXY_TARGET / QELOS_IP / QELOS_API_IP in development).',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const originalHost = getRequestHeader(event, 'host');
|
|
29
|
+
const path = event.path.split('?')[0] ?? event.path;
|
|
30
|
+
const target = joinURL(base, path);
|
|
31
|
+
|
|
32
|
+
return proxyRequest(event, target, {
|
|
33
|
+
fetch: globalThis.fetch,
|
|
34
|
+
cookieDomainRewrite: { '*': originalHost ?? '' },
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/sdk-factory.ts
CHANGED
|
@@ -1,111 +1,39 @@
|
|
|
1
1
|
import QelosSDK from '@qelos/sdk';
|
|
2
2
|
import type { QelosSDKOptions, FetchLike } from '@qelos/sdk/types';
|
|
3
|
-
import type
|
|
4
|
-
|
|
5
|
-
QelosTokenPair,
|
|
6
|
-
ResolvedTokens,
|
|
7
|
-
TokenRefreshHook,
|
|
8
|
-
} from './types';
|
|
9
|
-
import type { H3Event } from 'h3';
|
|
3
|
+
import { getRequestHeader, type H3Event } from 'h3';
|
|
4
|
+
import type { QelosNuxtRuntimeConfig } from './types';
|
|
10
5
|
|
|
11
6
|
export interface CreateSdkParams {
|
|
12
7
|
config: QelosNuxtRuntimeConfig;
|
|
13
|
-
/**
|
|
14
|
-
* Tokens for the current request. The factory mutates this object in
|
|
15
|
-
* place when a token refresh occurs, so callers can read the latest pair
|
|
16
|
-
* after the SDK has been used.
|
|
17
|
-
*/
|
|
18
|
-
tokens: QelosTokenPair;
|
|
19
8
|
event: H3Event;
|
|
20
|
-
onTokenRefresh?: TokenRefreshHook;
|
|
21
9
|
}
|
|
22
10
|
|
|
23
|
-
|
|
24
|
-
'/api/token/refresh',
|
|
25
|
-
'/api/cookie/refresh',
|
|
26
|
-
'/api/signin',
|
|
27
|
-
'/api/signup',
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
export function createRequestSdk({ config, tokens, event, onTokenRefresh }: CreateSdkParams): QelosSDK {
|
|
31
|
-
let sdk: QelosSDK;
|
|
32
|
-
let refreshInFlight: Promise<void> | null = null;
|
|
33
|
-
|
|
11
|
+
export function createRequestSdk({ config, event }: CreateSdkParams): QelosSDK {
|
|
34
12
|
const baseOptions: Partial<QelosSDKOptions> = config.sdkOptions || {};
|
|
35
13
|
|
|
36
|
-
async function performRefresh(): Promise<void> {
|
|
37
|
-
if (!tokens.refreshToken && !tokens.accessToken) {
|
|
38
|
-
throw new Error('no refresh token available');
|
|
39
|
-
}
|
|
40
|
-
const previous: QelosTokenPair = {
|
|
41
|
-
accessToken: tokens.accessToken,
|
|
42
|
-
refreshToken: tokens.refreshToken,
|
|
43
|
-
};
|
|
44
|
-
let refreshed: ResolvedTokens;
|
|
45
|
-
if (tokens.refreshToken) {
|
|
46
|
-
const result = await sdk.authentication.refreshToken(tokens.refreshToken);
|
|
47
|
-
refreshed = {
|
|
48
|
-
accessToken: result.payload.token,
|
|
49
|
-
refreshToken: result.payload.refreshToken,
|
|
50
|
-
};
|
|
51
|
-
} else {
|
|
52
|
-
const result = await sdk.authentication.refreshCookieToken(tokens.accessToken);
|
|
53
|
-
refreshed = {
|
|
54
|
-
accessToken: result.payload.cookieToken,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
tokens.accessToken = refreshed.accessToken;
|
|
58
|
-
tokens.refreshToken = refreshed.refreshToken;
|
|
59
|
-
if (onTokenRefresh) {
|
|
60
|
-
await onTokenRefresh({ event, oldTokens: previous, newTokens: refreshed, sdk });
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function ensureRefresh(): Promise<void> {
|
|
65
|
-
if (!refreshInFlight) {
|
|
66
|
-
refreshInFlight = performRefresh().finally(() => {
|
|
67
|
-
refreshInFlight = null;
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
return refreshInFlight;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
14
|
const options: QelosSDKOptions = {
|
|
74
15
|
appUrl: config.appUrl,
|
|
75
16
|
fetch: (globalThis.fetch as unknown as FetchLike),
|
|
76
|
-
forceRefresh: !config.apiToken,
|
|
77
17
|
...baseOptions,
|
|
78
18
|
};
|
|
79
19
|
|
|
80
20
|
if (config.apiToken) {
|
|
81
21
|
options.apiToken = config.apiToken;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
22
|
+
return new QelosSDK(options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
options.extraHeaders = async () => {
|
|
26
|
+
const headers: Record<string, string> = {};
|
|
27
|
+
const cookieHeader = getRequestHeader(event, 'cookie');
|
|
28
|
+
if (cookieHeader) {
|
|
29
|
+
headers.cookie = cookieHeader;
|
|
85
30
|
}
|
|
86
|
-
|
|
87
|
-
|
|
31
|
+
const authHeader = getRequestHeader(event, 'authorization');
|
|
32
|
+
if (authHeader) {
|
|
33
|
+
headers.authorization = authHeader;
|
|
88
34
|
}
|
|
35
|
+
return headers;
|
|
36
|
+
};
|
|
89
37
|
|
|
90
|
-
|
|
91
|
-
if (NO_AUTH_URLS.has(relativeUrl)) {
|
|
92
|
-
return {};
|
|
93
|
-
}
|
|
94
|
-
if (forceRefresh && tokens.refreshToken) {
|
|
95
|
-
await ensureRefresh();
|
|
96
|
-
}
|
|
97
|
-
const token = sdk?.authentication?.accessToken || tokens.accessToken;
|
|
98
|
-
if (token) {
|
|
99
|
-
return { authorization: 'Bearer ' + token };
|
|
100
|
-
}
|
|
101
|
-
return {};
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
options.onFailedRefreshToken = async () => {
|
|
105
|
-
await ensureRefresh();
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
sdk = new QelosSDK(options);
|
|
110
|
-
return sdk;
|
|
38
|
+
return new QelosSDK(options);
|
|
111
39
|
}
|
package/src/types.ts
CHANGED
|
@@ -3,11 +3,6 @@ import type { IUser } from '@qelos/sdk/dist/authentication';
|
|
|
3
3
|
import type { IWorkspace } from '@qelos/sdk/workspaces';
|
|
4
4
|
import type { QelosSDKOptions } from '@qelos/sdk/types';
|
|
5
5
|
|
|
6
|
-
export interface QelosTokenPair {
|
|
7
|
-
accessToken?: string;
|
|
8
|
-
refreshToken?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
6
|
export interface QelosNuxtRuntimeConfig {
|
|
12
7
|
/**
|
|
13
8
|
* Base URL of the Qelos backend (e.g. https://yourdomain.com).
|
|
@@ -15,17 +10,9 @@ export interface QelosNuxtRuntimeConfig {
|
|
|
15
10
|
appUrl: string;
|
|
16
11
|
/**
|
|
17
12
|
* Static API token used for service-to-service calls. When provided, no
|
|
18
|
-
* cookie
|
|
13
|
+
* cookie-based authentication is performed.
|
|
19
14
|
*/
|
|
20
15
|
apiToken?: string;
|
|
21
|
-
/**
|
|
22
|
-
* Cookie name carrying the Qelos access token. Defaults to `q_access_token`.
|
|
23
|
-
*/
|
|
24
|
-
accessTokenCookie?: string;
|
|
25
|
-
/**
|
|
26
|
-
* Cookie name carrying the Qelos refresh token. Defaults to `q_refresh_token`.
|
|
27
|
-
*/
|
|
28
|
-
refreshTokenCookie?: string;
|
|
29
16
|
/**
|
|
30
17
|
* Path to mount the server middleware on. Defaults to `''` (every request).
|
|
31
18
|
*/
|
|
@@ -44,22 +31,13 @@ export interface QelosNuxtRuntimeConfig {
|
|
|
44
31
|
* Optional extra options merged into the per-request SDK instance.
|
|
45
32
|
*/
|
|
46
33
|
sdkOptions?: Partial<QelosSDKOptions>;
|
|
34
|
+
/**
|
|
35
|
+
* If true, the catch-all `/api/**` proxy handler is not registered.
|
|
36
|
+
* Defaults to `false`.
|
|
37
|
+
*/
|
|
38
|
+
disableProxy?: boolean;
|
|
47
39
|
}
|
|
48
40
|
|
|
49
|
-
export interface ResolvedTokens {
|
|
50
|
-
accessToken: string;
|
|
51
|
-
refreshToken?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface TokenRefreshContext {
|
|
55
|
-
event: import('h3').H3Event;
|
|
56
|
-
oldTokens: QelosTokenPair;
|
|
57
|
-
newTokens: ResolvedTokens;
|
|
58
|
-
sdk: QelosSDK;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export type TokenRefreshHook = (ctx: TokenRefreshContext) => void | Promise<void>;
|
|
62
|
-
|
|
63
41
|
export interface QelosRequestContext {
|
|
64
42
|
/**
|
|
65
43
|
* The authenticated user, or `null` when anonymous.
|
|
@@ -75,13 +53,11 @@ export interface QelosRequestContext {
|
|
|
75
53
|
*/
|
|
76
54
|
workspaces: IWorkspace[];
|
|
77
55
|
/**
|
|
78
|
-
* SDK instance bound to the current request's
|
|
56
|
+
* SDK instance bound to the current request's cookies. `extraHeaders`
|
|
57
|
+
* re-reads cookies live on each call, so cookie rotations applied by the
|
|
58
|
+
* middleware are picked up automatically.
|
|
79
59
|
*/
|
|
80
60
|
sdk: QelosSDK;
|
|
81
|
-
/**
|
|
82
|
-
* Tokens read from the request. Mutated when a refresh occurs.
|
|
83
|
-
*/
|
|
84
|
-
tokens: QelosTokenPair;
|
|
85
61
|
}
|
|
86
62
|
|
|
87
63
|
declare module 'h3' {
|