@qelos/plugin-netlify-api 1.1.3 → 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 +75 -0
- package/integrators/index.d.ts +135 -0
- package/integrators/index.js +296 -0
- package/integrators/read-tokens.test.cjs +34 -0
- package/integrators/request-parse.js +82 -0
- package/integrators/sdk-factory.js +153 -0
- package/package.json +28 -1
package/README.md
CHANGED
|
@@ -59,3 +59,78 @@ If your publish directory is not `dist`, set `QELOS_NETLIFY_PUBLISH` to that fol
|
|
|
59
59
|
- **Unless `use_cdn_proxy`:** copies `qelos-api-proxy.ts` into your Netlify functions directory.
|
|
60
60
|
|
|
61
61
|
No need to commit redirects yourself; the plugin and `postbuild` maintain them each build.
|
|
62
|
+
|
|
63
|
+
## Integrator API (`@qelos/plugin-netlify-api/integrators`)
|
|
64
|
+
|
|
65
|
+
This package implements the same **middleware contract** as other Qelos integrators (e.g. `@qelos/integrator-express`): each wrapped handler receives `event.qelos` shaped as **`QelosRequestContext`** — `QelosContext<QelosSDK, IUser, IWorkspace>` from `@qelos/global-types`, with:
|
|
66
|
+
|
|
67
|
+
| Field | Meaning |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `user` | Authenticated user, or `null` |
|
|
70
|
+
| `workspace` | Active workspace, or `null` |
|
|
71
|
+
| `workspaces` | All workspaces for the user (from `sdk.workspaces.getList()`) |
|
|
72
|
+
| `sdk` | `@qelos/sdk` instance bound to this request’s tokens |
|
|
73
|
+
| `tokens` | `QelosTokenPair`; updated in place if a refresh runs |
|
|
74
|
+
|
|
75
|
+
Configuration follows **`QelosConfig`** / **`QelosNetlifyConfig`** (same base as Express): use **`appUrl`** as the Qelos backend base URL. For backward compatibility, **`apiHost`** is still accepted and normalized the same way as `QELOS_API_IP`.
|
|
76
|
+
|
|
77
|
+
Token refresh matches **`@qelos/integrator-express`**: the SDK may refresh access/refresh tokens; by default, refreshed cookies are collected as `Set-Cookie` header values and **merged into your function’s response** so the browser stays in sync. Override with **`onTokenRefresh`** (see `TokenRefreshContext` in the published types).
|
|
78
|
+
|
|
79
|
+
Peer dependency: **`@netlify/functions`** (declare handler types and install in the project that contains Netlify functions).
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install @netlify/functions @qelos/sdk
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// netlify/functions/whoami.ts
|
|
87
|
+
import {
|
|
88
|
+
withQelos,
|
|
89
|
+
requireUser,
|
|
90
|
+
type QelosRequestContext,
|
|
91
|
+
} from '@qelos/plugin-netlify-api/integrators';
|
|
92
|
+
|
|
93
|
+
export const handler = withQelos(
|
|
94
|
+
async (event) => {
|
|
95
|
+
const q: QelosRequestContext = event.qelos!;
|
|
96
|
+
const { user, workspace, workspaces, sdk, tokens } = q;
|
|
97
|
+
return {
|
|
98
|
+
statusCode: 200,
|
|
99
|
+
body: JSON.stringify({ user, workspace, workspaces, tokenPair: tokens }),
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
appUrl: process.env.QELOS_API_IP!,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
export const handlerProtected = requireUser(
|
|
108
|
+
async (event) => {
|
|
109
|
+
return { statusCode: 200, body: JSON.stringify(event.qelos!.user) };
|
|
110
|
+
},
|
|
111
|
+
{ appUrl: process.env.QELOS_API_IP! },
|
|
112
|
+
);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**`skipPaths`:** when the function path matches a prefix in `skipPaths`, the wrapper does **not** attach `event.qelos` (same idea as Express skipping middleware for `/health`, etc.).
|
|
116
|
+
|
|
117
|
+
Lower-level helpers:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import {
|
|
121
|
+
identifyUser,
|
|
122
|
+
createRequestSdk,
|
|
123
|
+
readTokensFromEvent,
|
|
124
|
+
normalizeIntegratorConfig,
|
|
125
|
+
} from '@qelos/plugin-netlify-api/integrators';
|
|
126
|
+
|
|
127
|
+
const identity = await identifyUser(event, { appUrl: 'https://api.example.com' });
|
|
128
|
+
// identity includes user, workspace, workspaces, sdk, tokens, refreshedCookies
|
|
129
|
+
|
|
130
|
+
const config = normalizeIntegratorConfig({ appUrl: 'https://api.example.com' });
|
|
131
|
+
const tokens = readTokensFromEvent(event, config);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Package layout
|
|
135
|
+
|
|
136
|
+
The Netlify plugin stays under **`packages/plugin-netlify-api`** (historical location before the `integrators/*` convention). Types and behavior are aligned with **`@qelos/integrator-express`**; only the transport differs (Netlify `HandlerEvent` / response object vs Express `req` / `res`).
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type QelosSDK from '@qelos/sdk';
|
|
2
|
+
import type { IUser } from '@qelos/sdk/dist/authentication';
|
|
3
|
+
import type { IWorkspace } from '@qelos/sdk/workspaces';
|
|
4
|
+
import type { QelosSDKOptions } from '@qelos/sdk/types';
|
|
5
|
+
import type {
|
|
6
|
+
Handler,
|
|
7
|
+
HandlerContext,
|
|
8
|
+
HandlerEvent,
|
|
9
|
+
} from '@netlify/functions';
|
|
10
|
+
import type {
|
|
11
|
+
QelosConfig,
|
|
12
|
+
QelosContext,
|
|
13
|
+
QelosTokenPair,
|
|
14
|
+
ResolvedTokens,
|
|
15
|
+
} from '@qelos/global-types';
|
|
16
|
+
|
|
17
|
+
export type { QelosTokenPair, ResolvedTokens } from '@qelos/global-types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Same per-request context type as `@qelos/integrator-express` (`req.qelos`).
|
|
21
|
+
*/
|
|
22
|
+
export type QelosRequestContext = QelosContext<QelosSDK, IUser, IWorkspace>;
|
|
23
|
+
|
|
24
|
+
export interface QelosNetlifyConfig extends QelosConfig {
|
|
25
|
+
/**
|
|
26
|
+
* Optional extra options merged into the per-request SDK instance.
|
|
27
|
+
*/
|
|
28
|
+
sdkOptions?: Partial<QelosSDKOptions>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TokenRefreshContext {
|
|
32
|
+
event: HandlerEvent;
|
|
33
|
+
/**
|
|
34
|
+
* Collector for `Set-Cookie` header values; merged into the function response by
|
|
35
|
+
* `withQelos` / `requireUser` (same semantics as writing cookies on Express `res`).
|
|
36
|
+
*/
|
|
37
|
+
responseCookies: string[];
|
|
38
|
+
oldTokens: QelosTokenPair;
|
|
39
|
+
newTokens: ResolvedTokens;
|
|
40
|
+
sdk: QelosSDK;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type TokenRefreshHook = (ctx: TokenRefreshContext) => void | Promise<void>;
|
|
44
|
+
|
|
45
|
+
export interface NetlifyIntegratorOptions extends Partial<QelosNetlifyConfig> {
|
|
46
|
+
/**
|
|
47
|
+
* Override tenant resolved via `tenant` header / `QELOS_TENANT`.
|
|
48
|
+
*/
|
|
49
|
+
tenant?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Public host forwarded as `tenanthost` on SDK requests. Defaults to
|
|
52
|
+
* `x-forwarded-host`, then `host`, then `rawUrl`.
|
|
53
|
+
*/
|
|
54
|
+
tenantHost?: string;
|
|
55
|
+
/**
|
|
56
|
+
* @deprecated Prefer {@link QelosConfig.appUrl}. Resolved via {@link resolveApiHost}
|
|
57
|
+
* when `appUrl` is omitted (same as legacy `QELOS_API_IP`).
|
|
58
|
+
*/
|
|
59
|
+
apiHost?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Called after a successful token refresh. Defaults to appending refreshed tokens as
|
|
62
|
+
* `Set-Cookie` entries on {@link TokenRefreshContext.responseCookies}.
|
|
63
|
+
*/
|
|
64
|
+
onTokenRefresh?: TokenRefreshHook;
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the active workspace. Defaults to the first workspace from
|
|
67
|
+
* `sdk.workspaces.getList()` (same as Express).
|
|
68
|
+
*/
|
|
69
|
+
resolveWorkspace?: (params: {
|
|
70
|
+
event: HandlerEvent;
|
|
71
|
+
user: IUser;
|
|
72
|
+
workspaces: IWorkspace[];
|
|
73
|
+
}) => IWorkspace | null | Promise<IWorkspace | null>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Backward-compatible alias for {@link NetlifyIntegratorOptions}. */
|
|
77
|
+
export type IntegratorOptions = NetlifyIntegratorOptions;
|
|
78
|
+
|
|
79
|
+
export interface CreateNetlifySdkParams {
|
|
80
|
+
config: QelosNetlifyConfig;
|
|
81
|
+
tokens: QelosTokenPair;
|
|
82
|
+
event: HandlerEvent;
|
|
83
|
+
/** Mutable list of `Set-Cookie` header line values for the outgoing function response */
|
|
84
|
+
responseCookies: string[];
|
|
85
|
+
onTokenRefresh?: TokenRefreshHook;
|
|
86
|
+
/** Merged into each SDK request (e.g. `tenanthost`, impersonation headers) */
|
|
87
|
+
staticRequestHeaders?: Record<string, string>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Alias mirroring `@qelos/integrator-express` naming */
|
|
91
|
+
export type CreateSdkParams = CreateNetlifySdkParams;
|
|
92
|
+
|
|
93
|
+
export interface QelosHandlerEvent extends HandlerEvent {
|
|
94
|
+
qelos?: QelosRequestContext;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type QelosHandler = (
|
|
98
|
+
event: QelosHandlerEvent,
|
|
99
|
+
context: HandlerContext,
|
|
100
|
+
) => ReturnType<Handler>;
|
|
101
|
+
|
|
102
|
+
export function normalizeIntegratorConfig(
|
|
103
|
+
options?: NetlifyIntegratorOptions,
|
|
104
|
+
): QelosNetlifyConfig;
|
|
105
|
+
|
|
106
|
+
export function readTokensFromEvent(
|
|
107
|
+
event: HandlerEvent,
|
|
108
|
+
config: QelosNetlifyConfig,
|
|
109
|
+
): QelosTokenPair;
|
|
110
|
+
|
|
111
|
+
export function createRequestSdk(params: CreateNetlifySdkParams): QelosSDK;
|
|
112
|
+
|
|
113
|
+
export function identifyUser(
|
|
114
|
+
event: HandlerEvent,
|
|
115
|
+
options?: NetlifyIntegratorOptions,
|
|
116
|
+
): Promise<{
|
|
117
|
+
user: IUser;
|
|
118
|
+
workspace: IWorkspace | null;
|
|
119
|
+
workspaces: IWorkspace[];
|
|
120
|
+
sdk: QelosSDK;
|
|
121
|
+
tokens: QelosTokenPair;
|
|
122
|
+
refreshedCookies: string[];
|
|
123
|
+
} | null>;
|
|
124
|
+
|
|
125
|
+
export function withQelos(
|
|
126
|
+
handler: QelosHandler,
|
|
127
|
+
options?: NetlifyIntegratorOptions,
|
|
128
|
+
): Handler;
|
|
129
|
+
|
|
130
|
+
export function requireUser(
|
|
131
|
+
handler: QelosHandler,
|
|
132
|
+
options?: NetlifyIntegratorOptions,
|
|
133
|
+
): Handler;
|
|
134
|
+
|
|
135
|
+
export function resolveApiHost(override?: string): string;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Netlify implementation of the Qelos integrator contract — same per-request
|
|
5
|
+
* {@link QelosRequestContext} shape as `@qelos/integrator-express` (`user`,
|
|
6
|
+
* `workspace`, `workspaces`, `sdk`, `tokens`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { createRequestSdk } = require('./sdk-factory');
|
|
10
|
+
const {
|
|
11
|
+
resolveApiHost,
|
|
12
|
+
normalizeIntegratorConfig,
|
|
13
|
+
pickHeader,
|
|
14
|
+
readTokensFromEvent,
|
|
15
|
+
} = require('./request-parse');
|
|
16
|
+
|
|
17
|
+
function publicHostForEvent(event) {
|
|
18
|
+
const forwarded = pickHeader(event.headers, 'x-forwarded-host');
|
|
19
|
+
if (forwarded) return forwarded.split(',')[0].trim();
|
|
20
|
+
const host = pickHeader(event.headers, 'host');
|
|
21
|
+
if (host) return host;
|
|
22
|
+
if (event.rawUrl) {
|
|
23
|
+
try {
|
|
24
|
+
return new URL(event.rawUrl).hostname;
|
|
25
|
+
} catch {
|
|
26
|
+
// fall through
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Headers forwarded on every SDK request for multi-tenant / impersonation
|
|
34
|
+
* (matches the former `/api/me` integration identity headers).
|
|
35
|
+
*/
|
|
36
|
+
function buildStaticRequestHeaders(event, options) {
|
|
37
|
+
const headers = {};
|
|
38
|
+
const tenantHost = options.tenantHost || publicHostForEvent(event);
|
|
39
|
+
if (tenantHost) headers.tenanthost = tenantHost;
|
|
40
|
+
|
|
41
|
+
const tenant =
|
|
42
|
+
options.tenant ||
|
|
43
|
+
pickHeader(event.headers, 'tenant') ||
|
|
44
|
+
process.env.QELOS_TENANT;
|
|
45
|
+
if (tenant) headers.tenant = tenant;
|
|
46
|
+
|
|
47
|
+
for (const passthrough of [
|
|
48
|
+
'x-impersonate-tenant',
|
|
49
|
+
'x-impersonate-user',
|
|
50
|
+
'x-impersonate-workspace',
|
|
51
|
+
]) {
|
|
52
|
+
const value = pickHeader(event.headers, passthrough);
|
|
53
|
+
if (value) headers[passthrough] = value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return headers;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function shouldSkip(event, config) {
|
|
60
|
+
if (!config.skipPaths?.length) return false;
|
|
61
|
+
let path = event.path;
|
|
62
|
+
if (!path && event.rawUrl) {
|
|
63
|
+
try {
|
|
64
|
+
path = new URL(event.rawUrl).pathname;
|
|
65
|
+
} catch {
|
|
66
|
+
path = '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return config.skipPaths.some((prefix) => path.startsWith(prefix));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mergeResponseCookies(result, responseCookies) {
|
|
73
|
+
if (!responseCookies.length) return result;
|
|
74
|
+
const base = result && typeof result === 'object' ? result : {};
|
|
75
|
+
const headers = { ...(base.headers || {}) };
|
|
76
|
+
const existing = headers['set-cookie'];
|
|
77
|
+
const merged = Array.isArray(existing)
|
|
78
|
+
? [...existing, ...responseCookies]
|
|
79
|
+
: existing
|
|
80
|
+
? [existing, ...responseCookies]
|
|
81
|
+
: [...responseCookies];
|
|
82
|
+
headers['set-cookie'] = merged;
|
|
83
|
+
return { ...base, headers };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function attachQelosContext(event, ctx) {
|
|
87
|
+
Object.defineProperty(event, 'qelos', {
|
|
88
|
+
value: ctx,
|
|
89
|
+
enumerable: true,
|
|
90
|
+
configurable: true,
|
|
91
|
+
writable: true,
|
|
92
|
+
});
|
|
93
|
+
return event;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function populateUserAndWorkspaces(ctx, event, options) {
|
|
97
|
+
try {
|
|
98
|
+
ctx.user = await ctx.sdk.authentication.getLoggedInUser();
|
|
99
|
+
} catch {
|
|
100
|
+
ctx.user = null;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
ctx.workspaces = await ctx.sdk.workspaces.getList();
|
|
106
|
+
} catch {
|
|
107
|
+
ctx.workspaces = [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (ctx.user && ctx.workspaces.length) {
|
|
111
|
+
if (options.resolveWorkspace) {
|
|
112
|
+
ctx.workspace =
|
|
113
|
+
(await options.resolveWorkspace({
|
|
114
|
+
event,
|
|
115
|
+
user: ctx.user,
|
|
116
|
+
workspaces: ctx.workspaces,
|
|
117
|
+
})) || null;
|
|
118
|
+
} else {
|
|
119
|
+
ctx.workspace = ctx.workspaces[0] || null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve user/workspace/workspaces using the same SDK flow as Express.
|
|
127
|
+
* Returns `null` when there is no auth material or identity cannot be resolved.
|
|
128
|
+
*/
|
|
129
|
+
async function identifyUser(event, options = {}) {
|
|
130
|
+
if (!event) return null;
|
|
131
|
+
|
|
132
|
+
const config = normalizeIntegratorConfig(options);
|
|
133
|
+
const tokens = readTokensFromEvent(event, config);
|
|
134
|
+
const responseCookies = [];
|
|
135
|
+
const staticRequestHeaders = buildStaticRequestHeaders(event, options);
|
|
136
|
+
|
|
137
|
+
const hasAuthMaterial = Boolean(
|
|
138
|
+
config.apiToken || tokens.accessToken || tokens.refreshToken,
|
|
139
|
+
);
|
|
140
|
+
if (!hasAuthMaterial) return null;
|
|
141
|
+
|
|
142
|
+
const sdk = createRequestSdk({
|
|
143
|
+
config,
|
|
144
|
+
tokens,
|
|
145
|
+
event,
|
|
146
|
+
responseCookies,
|
|
147
|
+
onTokenRefresh: options.onTokenRefresh,
|
|
148
|
+
staticRequestHeaders,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const ctx = {
|
|
152
|
+
user: null,
|
|
153
|
+
workspace: null,
|
|
154
|
+
workspaces: [],
|
|
155
|
+
sdk,
|
|
156
|
+
tokens,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
await populateUserAndWorkspaces(ctx, event, options);
|
|
160
|
+
if (!ctx.user) return null;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
user: ctx.user,
|
|
164
|
+
workspace: ctx.workspace,
|
|
165
|
+
workspaces: ctx.workspaces,
|
|
166
|
+
sdk: ctx.sdk,
|
|
167
|
+
tokens: ctx.tokens,
|
|
168
|
+
refreshedCookies: responseCookies,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function withQelos(handler, options = {}) {
|
|
173
|
+
return async function qelosWrappedHandler(event, context) {
|
|
174
|
+
if (shouldSkip(event, normalizeIntegratorConfig(options))) {
|
|
175
|
+
return handler(event, context);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const config = normalizeIntegratorConfig(options);
|
|
179
|
+
const tokens = readTokensFromEvent(event, config);
|
|
180
|
+
const responseCookies = [];
|
|
181
|
+
const staticRequestHeaders = buildStaticRequestHeaders(event, options);
|
|
182
|
+
|
|
183
|
+
const sdk = createRequestSdk({
|
|
184
|
+
config,
|
|
185
|
+
tokens,
|
|
186
|
+
event,
|
|
187
|
+
responseCookies,
|
|
188
|
+
onTokenRefresh: options.onTokenRefresh,
|
|
189
|
+
staticRequestHeaders,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const ctx = {
|
|
193
|
+
user: null,
|
|
194
|
+
workspace: null,
|
|
195
|
+
workspaces: [],
|
|
196
|
+
sdk,
|
|
197
|
+
tokens,
|
|
198
|
+
};
|
|
199
|
+
attachQelosContext(event, ctx);
|
|
200
|
+
|
|
201
|
+
const hasAuthMaterial = Boolean(
|
|
202
|
+
config.apiToken || tokens.accessToken || tokens.refreshToken,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (!hasAuthMaterial) {
|
|
206
|
+
if (config.requireAuth) {
|
|
207
|
+
return {
|
|
208
|
+
statusCode: 401,
|
|
209
|
+
headers: { 'content-type': 'application/json' },
|
|
210
|
+
body: JSON.stringify({ code: 'UNAUTHORIZED' }),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return mergeResponseCookies(
|
|
214
|
+
await handler(event, context),
|
|
215
|
+
responseCookies,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ok = await populateUserAndWorkspaces(ctx, event, options);
|
|
220
|
+
if (!ok) {
|
|
221
|
+
if (config.requireAuth) {
|
|
222
|
+
return {
|
|
223
|
+
statusCode: 401,
|
|
224
|
+
headers: { 'content-type': 'application/json' },
|
|
225
|
+
body: JSON.stringify({ code: 'UNAUTHORIZED' }),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return mergeResponseCookies(
|
|
229
|
+
await handler(event, context),
|
|
230
|
+
responseCookies,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return mergeResponseCookies(
|
|
235
|
+
await handler(event, context),
|
|
236
|
+
responseCookies,
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function requireUser(handler, options = {}) {
|
|
242
|
+
return async function qelosRequireUserHandler(event, context) {
|
|
243
|
+
if (shouldSkip(event, normalizeIntegratorConfig(options))) {
|
|
244
|
+
return handler(event, context);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const config = normalizeIntegratorConfig(options);
|
|
248
|
+
const tokens = readTokensFromEvent(event, config);
|
|
249
|
+
const responseCookies = [];
|
|
250
|
+
const staticRequestHeaders = buildStaticRequestHeaders(event, options);
|
|
251
|
+
|
|
252
|
+
const sdk = createRequestSdk({
|
|
253
|
+
config,
|
|
254
|
+
tokens,
|
|
255
|
+
event,
|
|
256
|
+
responseCookies,
|
|
257
|
+
onTokenRefresh: options.onTokenRefresh,
|
|
258
|
+
staticRequestHeaders,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const ctx = {
|
|
262
|
+
user: null,
|
|
263
|
+
workspace: null,
|
|
264
|
+
workspaces: [],
|
|
265
|
+
sdk,
|
|
266
|
+
tokens,
|
|
267
|
+
};
|
|
268
|
+
attachQelosContext(event, ctx);
|
|
269
|
+
|
|
270
|
+
const hasAuthMaterial = Boolean(
|
|
271
|
+
config.apiToken || tokens.accessToken || tokens.refreshToken,
|
|
272
|
+
);
|
|
273
|
+
if (!hasAuthMaterial || !(await populateUserAndWorkspaces(ctx, event, options))) {
|
|
274
|
+
return {
|
|
275
|
+
statusCode: 401,
|
|
276
|
+
headers: { 'content-type': 'application/json' },
|
|
277
|
+
body: JSON.stringify({ code: 'UNAUTHORIZED' }),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return mergeResponseCookies(
|
|
282
|
+
await handler(event, context),
|
|
283
|
+
responseCookies,
|
|
284
|
+
);
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
identifyUser,
|
|
290
|
+
withQelos,
|
|
291
|
+
requireUser,
|
|
292
|
+
resolveApiHost,
|
|
293
|
+
normalizeIntegratorConfig,
|
|
294
|
+
readTokensFromEvent,
|
|
295
|
+
createRequestSdk,
|
|
296
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const {
|
|
6
|
+
readTokensFromEvent,
|
|
7
|
+
normalizeIntegratorConfig,
|
|
8
|
+
} = require('./request-parse.js');
|
|
9
|
+
|
|
10
|
+
test('readTokensFromEvent prefers Authorization bearer over cookie token', () => {
|
|
11
|
+
const event = {
|
|
12
|
+
headers: {
|
|
13
|
+
authorization: 'Bearer from-header',
|
|
14
|
+
cookie: 'q_access_token=from-cookie',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const config = normalizeIntegratorConfig({
|
|
18
|
+
appUrl: 'http://localhost',
|
|
19
|
+
});
|
|
20
|
+
const tokens = readTokensFromEvent(event, config);
|
|
21
|
+
assert.equal(tokens.accessToken, 'from-header');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('readTokensFromEvent reads refresh cookie', () => {
|
|
25
|
+
const event = {
|
|
26
|
+
headers: {
|
|
27
|
+
cookie: 'q_refresh_token=abc; q_access_token=def',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const config = normalizeIntegratorConfig({ appUrl: 'http://localhost' });
|
|
31
|
+
const tokens = readTokensFromEvent(event, config);
|
|
32
|
+
assert.equal(tokens.accessToken, 'def');
|
|
33
|
+
assert.equal(tokens.refreshToken, 'abc');
|
|
34
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_API_URL = 'http://159.203.152.168';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_ACCESS_COOKIE = 'q_access_token';
|
|
6
|
+
const DEFAULT_REFRESH_COOKIE = 'q_refresh_token';
|
|
7
|
+
|
|
8
|
+
function resolveApiHost(override) {
|
|
9
|
+
const raw =
|
|
10
|
+
override ?? process.env.QELOS_API_IP ?? process.env.API_HOST ?? DEFAULT_API_URL;
|
|
11
|
+
const trimmed = String(raw).trim().replace(/\/$/, '');
|
|
12
|
+
if (!trimmed) return DEFAULT_API_URL;
|
|
13
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
14
|
+
return `http://${trimmed}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeIntegratorConfig(options = {}) {
|
|
18
|
+
const appUrl =
|
|
19
|
+
(typeof options.appUrl === 'string' && options.appUrl.trim()) ||
|
|
20
|
+
resolveApiHost(options.apiHost);
|
|
21
|
+
return {
|
|
22
|
+
appUrl,
|
|
23
|
+
apiToken: options.apiToken,
|
|
24
|
+
accessTokenCookie: options.accessTokenCookie,
|
|
25
|
+
refreshTokenCookie: options.refreshTokenCookie,
|
|
26
|
+
requireAuth: options.requireAuth,
|
|
27
|
+
skipPaths: options.skipPaths,
|
|
28
|
+
sdkOptions: options.sdkOptions,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pickHeader(headers, name) {
|
|
33
|
+
if (!headers) return undefined;
|
|
34
|
+
const want = name.toLowerCase();
|
|
35
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
36
|
+
if (k.toLowerCase() !== want) continue;
|
|
37
|
+
if (v == null) continue;
|
|
38
|
+
const value = Array.isArray(v) ? v[0] : v;
|
|
39
|
+
if (typeof value === 'string' && value.length > 0) return value;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readCookie(headers, name) {
|
|
45
|
+
const header = pickHeader(headers, 'cookie');
|
|
46
|
+
if (!header) return undefined;
|
|
47
|
+
const prefix = `${name}=`;
|
|
48
|
+
for (const part of header.split(';')) {
|
|
49
|
+
const trimmed = part.trim();
|
|
50
|
+
if (trimmed.startsWith(prefix)) {
|
|
51
|
+
try {
|
|
52
|
+
return decodeURIComponent(trimmed.slice(prefix.length));
|
|
53
|
+
} catch {
|
|
54
|
+
return trimmed.slice(prefix.length);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readTokensFromEvent(event, config) {
|
|
62
|
+
const accessCookie = config.accessTokenCookie || DEFAULT_ACCESS_COOKIE;
|
|
63
|
+
const refreshCookie = config.refreshTokenCookie || DEFAULT_REFRESH_COOKIE;
|
|
64
|
+
const cookieAccess = readCookie(event.headers, accessCookie);
|
|
65
|
+
const cookieRefresh = readCookie(event.headers, refreshCookie);
|
|
66
|
+
const authHeader = pickHeader(event.headers, 'authorization');
|
|
67
|
+
const headerAccess =
|
|
68
|
+
authHeader && authHeader.toLowerCase().startsWith('bearer ')
|
|
69
|
+
? authHeader.slice(7).trim()
|
|
70
|
+
: undefined;
|
|
71
|
+
return {
|
|
72
|
+
accessToken: headerAccess || cookieAccess || undefined,
|
|
73
|
+
refreshToken: cookieRefresh || undefined,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
resolveApiHost,
|
|
79
|
+
normalizeIntegratorConfig,
|
|
80
|
+
pickHeader,
|
|
81
|
+
readTokensFromEvent,
|
|
82
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const sdkModule = require('@qelos/sdk');
|
|
4
|
+
const QelosSDK = sdkModule.default || sdkModule;
|
|
5
|
+
|
|
6
|
+
const NO_AUTH_URLS = new Set([
|
|
7
|
+
'/api/token/refresh',
|
|
8
|
+
'/api/cookie/refresh',
|
|
9
|
+
'/api/signin',
|
|
10
|
+
'/api/signup',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const DEFAULT_ACCESS_COOKIE = 'q_access_token';
|
|
14
|
+
const DEFAULT_REFRESH_COOKIE = 'q_refresh_token';
|
|
15
|
+
|
|
16
|
+
function serializeCookie(name, value, secure) {
|
|
17
|
+
const parts = [
|
|
18
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
19
|
+
'Path=/',
|
|
20
|
+
'HttpOnly',
|
|
21
|
+
'SameSite=Lax',
|
|
22
|
+
];
|
|
23
|
+
if (secure) parts.push('Secure');
|
|
24
|
+
return parts.join('; ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Serializes refreshed tokens as Set-Cookie header value strings (same rules as
|
|
29
|
+
* @qelos/integrator-express writeTokensToCookies).
|
|
30
|
+
*/
|
|
31
|
+
function tokensToSetCookieHeaderValues(config, resolved) {
|
|
32
|
+
const accessCookie = config.accessTokenCookie || DEFAULT_ACCESS_COOKIE;
|
|
33
|
+
const refreshCookie = config.refreshTokenCookie || DEFAULT_REFRESH_COOKIE;
|
|
34
|
+
const secure = !/^http:\/\//i.test(config.appUrl);
|
|
35
|
+
const out = [
|
|
36
|
+
serializeCookie(accessCookie, resolved.accessToken, secure),
|
|
37
|
+
];
|
|
38
|
+
if (resolved.refreshToken) {
|
|
39
|
+
out.push(serializeCookie(refreshCookie, resolved.refreshToken, secure));
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {Record<string, unknown>} params */
|
|
45
|
+
function createRequestSdk(params) {
|
|
46
|
+
const {
|
|
47
|
+
config,
|
|
48
|
+
tokens,
|
|
49
|
+
event,
|
|
50
|
+
responseCookies,
|
|
51
|
+
onTokenRefresh,
|
|
52
|
+
staticRequestHeaders,
|
|
53
|
+
} = params;
|
|
54
|
+
let sdk;
|
|
55
|
+
let refreshInFlight = null;
|
|
56
|
+
const baseOptions = config.sdkOptions || {};
|
|
57
|
+
const forwarded = staticRequestHeaders || {};
|
|
58
|
+
|
|
59
|
+
async function performRefresh() {
|
|
60
|
+
if (!tokens.refreshToken && !tokens.accessToken) {
|
|
61
|
+
throw new Error('no refresh token available');
|
|
62
|
+
}
|
|
63
|
+
const previous = {
|
|
64
|
+
accessToken: tokens.accessToken,
|
|
65
|
+
refreshToken: tokens.refreshToken,
|
|
66
|
+
};
|
|
67
|
+
let refreshed;
|
|
68
|
+
if (tokens.refreshToken) {
|
|
69
|
+
const result = await sdk.authentication.refreshToken(tokens.refreshToken);
|
|
70
|
+
refreshed = {
|
|
71
|
+
accessToken: result.payload.token,
|
|
72
|
+
refreshToken: result.payload.refreshToken,
|
|
73
|
+
};
|
|
74
|
+
} else {
|
|
75
|
+
const result = await sdk.authentication.refreshCookieToken(
|
|
76
|
+
tokens.accessToken,
|
|
77
|
+
);
|
|
78
|
+
refreshed = {
|
|
79
|
+
accessToken: result.payload.cookieToken,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
tokens.accessToken = refreshed.accessToken;
|
|
83
|
+
tokens.refreshToken = refreshed.refreshToken;
|
|
84
|
+
const hook = onTokenRefresh || defaultNetlifyTokenRefresh;
|
|
85
|
+
await hook({
|
|
86
|
+
event,
|
|
87
|
+
responseCookies,
|
|
88
|
+
oldTokens: previous,
|
|
89
|
+
newTokens: refreshed,
|
|
90
|
+
sdk,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function defaultNetlifyTokenRefresh(ctx) {
|
|
95
|
+
const values = tokensToSetCookieHeaderValues(config, ctx.newTokens);
|
|
96
|
+
for (const v of values) {
|
|
97
|
+
ctx.responseCookies.push(v);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ensureRefresh() {
|
|
102
|
+
if (!refreshInFlight) {
|
|
103
|
+
refreshInFlight = performRefresh().finally(() => {
|
|
104
|
+
refreshInFlight = null;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return refreshInFlight;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const options = {
|
|
111
|
+
appUrl: config.appUrl,
|
|
112
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
113
|
+
forceRefresh: !config.apiToken,
|
|
114
|
+
...baseOptions,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (config.apiToken) {
|
|
118
|
+
options.apiToken = config.apiToken;
|
|
119
|
+
} else {
|
|
120
|
+
if (tokens.accessToken) {
|
|
121
|
+
options.accessToken = tokens.accessToken;
|
|
122
|
+
}
|
|
123
|
+
if (tokens.refreshToken) {
|
|
124
|
+
options.refreshToken = tokens.refreshToken;
|
|
125
|
+
}
|
|
126
|
+
options.extraHeaders = async (relativeUrl, forceRefresh) => {
|
|
127
|
+
const headers = { ...forwarded };
|
|
128
|
+
if (NO_AUTH_URLS.has(relativeUrl)) {
|
|
129
|
+
return headers;
|
|
130
|
+
}
|
|
131
|
+
if (forceRefresh && tokens.refreshToken) {
|
|
132
|
+
await ensureRefresh();
|
|
133
|
+
}
|
|
134
|
+
const token =
|
|
135
|
+
sdk?.authentication?.accessToken || tokens.accessToken;
|
|
136
|
+
if (token) {
|
|
137
|
+
headers.authorization = 'Bearer ' + token;
|
|
138
|
+
}
|
|
139
|
+
return headers;
|
|
140
|
+
};
|
|
141
|
+
options.onFailedRefreshToken = async () => {
|
|
142
|
+
await ensureRefresh();
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
sdk = new QelosSDK(options);
|
|
147
|
+
return sdk;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
createRequestSdk,
|
|
152
|
+
tokensToSetCookieHeaderValues,
|
|
153
|
+
};
|
package/package.json
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qelos/plugin-netlify-api",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Netlify build plugin: proxy /api/* to Qelos API and inject config",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"qelos-netlify-patch-redirects": "./scripts/patch-redirects.cjs"
|
|
8
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"require": "./index.js",
|
|
12
|
+
"default": "./index.js"
|
|
13
|
+
},
|
|
14
|
+
"./integrators": {
|
|
15
|
+
"types": "./integrators/index.d.ts",
|
|
16
|
+
"import": "./integrators/index.js",
|
|
17
|
+
"require": "./integrators/index.js",
|
|
18
|
+
"default": "./integrators/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
9
22
|
"files": [
|
|
10
23
|
"index.js",
|
|
11
24
|
"manifest.yml",
|
|
12
25
|
"functions",
|
|
26
|
+
"integrators",
|
|
13
27
|
"scripts"
|
|
14
28
|
],
|
|
15
29
|
"author": "David Meir-Levy <davidmeirlevy@gmail.com>",
|
|
@@ -19,5 +33,18 @@
|
|
|
19
33
|
},
|
|
20
34
|
"engines": {
|
|
21
35
|
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@qelos/global-types": "^4.1.0",
|
|
39
|
+
"@qelos/sdk": "^4.1.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@netlify/functions": ">=2.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@netlify/functions": "^2.8.2"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test integrators/read-tokens.test.cjs"
|
|
22
49
|
}
|
|
23
50
|
}
|