@scalemule/nextjs 0.0.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.
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/client.d.mts +163 -0
- package/dist/client.d.ts +163 -0
- package/dist/client.js +700 -0
- package/dist/client.mjs +697 -0
- package/dist/index-BkacIKdu.d.mts +807 -0
- package/dist/index-BkacIKdu.d.ts +807 -0
- package/dist/index.d.mts +418 -0
- package/dist/index.d.ts +418 -0
- package/dist/index.js +3103 -0
- package/dist/index.mjs +3084 -0
- package/dist/server/auth.d.mts +38 -0
- package/dist/server/auth.d.ts +38 -0
- package/dist/server/auth.js +1088 -0
- package/dist/server/auth.mjs +1083 -0
- package/dist/server/index.d.mts +868 -0
- package/dist/server/index.d.ts +868 -0
- package/dist/server/index.js +2028 -0
- package/dist/server/index.mjs +1972 -0
- package/dist/server/webhook-handler.d.mts +2 -0
- package/dist/server/webhook-handler.d.ts +2 -0
- package/dist/server/webhook-handler.js +56 -0
- package/dist/server/webhook-handler.mjs +54 -0
- package/dist/testing.d.mts +109 -0
- package/dist/testing.d.ts +109 -0
- package/dist/testing.js +134 -0
- package/dist/testing.mjs +128 -0
- package/dist/webhook-handler-BPNqhuwL.d.ts +728 -0
- package/dist/webhook-handler-C-5_Ey1T.d.mts +728 -0
- package/package.json +99 -0
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
import { cookies } from 'next/headers';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
4
|
+
|
|
5
|
+
// src/server/context.ts
|
|
6
|
+
function validateIP(ip) {
|
|
7
|
+
if (!ip) return void 0;
|
|
8
|
+
const trimmed = ip.trim();
|
|
9
|
+
if (!trimmed) return void 0;
|
|
10
|
+
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
11
|
+
const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){0,6}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/;
|
|
12
|
+
const ipv4MappedRegex = /^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i;
|
|
13
|
+
if (ipv4Regex.test(trimmed) || ipv6Regex.test(trimmed) || ipv4MappedRegex.test(trimmed)) {
|
|
14
|
+
return trimmed;
|
|
15
|
+
}
|
|
16
|
+
return void 0;
|
|
17
|
+
}
|
|
18
|
+
function extractClientContext(request) {
|
|
19
|
+
const headers = request.headers;
|
|
20
|
+
let ip;
|
|
21
|
+
const cfConnectingIp = headers.get("cf-connecting-ip");
|
|
22
|
+
if (cfConnectingIp) {
|
|
23
|
+
ip = validateIP(cfConnectingIp);
|
|
24
|
+
}
|
|
25
|
+
if (!ip) {
|
|
26
|
+
const doConnectingIp = headers.get("do-connecting-ip");
|
|
27
|
+
if (doConnectingIp) {
|
|
28
|
+
ip = validateIP(doConnectingIp);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!ip) {
|
|
32
|
+
const realIp = headers.get("x-real-ip");
|
|
33
|
+
if (realIp) {
|
|
34
|
+
ip = validateIP(realIp);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!ip) {
|
|
38
|
+
const forwardedFor = headers.get("x-forwarded-for");
|
|
39
|
+
if (forwardedFor) {
|
|
40
|
+
const firstIp = forwardedFor.split(",")[0]?.trim();
|
|
41
|
+
ip = validateIP(firstIp);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!ip) {
|
|
45
|
+
const vercelForwarded = headers.get("x-vercel-forwarded-for");
|
|
46
|
+
if (vercelForwarded) {
|
|
47
|
+
const firstIp = vercelForwarded.split(",")[0]?.trim();
|
|
48
|
+
ip = validateIP(firstIp);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!ip) {
|
|
52
|
+
const trueClientIp = headers.get("true-client-ip");
|
|
53
|
+
if (trueClientIp) {
|
|
54
|
+
ip = validateIP(trueClientIp);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!ip && request.ip) {
|
|
58
|
+
ip = validateIP(request.ip);
|
|
59
|
+
}
|
|
60
|
+
const userAgent = headers.get("user-agent") || void 0;
|
|
61
|
+
const deviceFingerprint = headers.get("x-device-fingerprint") || void 0;
|
|
62
|
+
const referrer = headers.get("referer") || void 0;
|
|
63
|
+
return {
|
|
64
|
+
ip,
|
|
65
|
+
userAgent,
|
|
66
|
+
deviceFingerprint,
|
|
67
|
+
referrer
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function extractClientContextFromReq(req) {
|
|
71
|
+
const headers = req.headers;
|
|
72
|
+
const getHeader = (name) => {
|
|
73
|
+
const value = headers[name.toLowerCase()];
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
return value[0];
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
let ip;
|
|
80
|
+
const cfConnectingIp = getHeader("cf-connecting-ip");
|
|
81
|
+
if (cfConnectingIp) {
|
|
82
|
+
ip = validateIP(cfConnectingIp);
|
|
83
|
+
}
|
|
84
|
+
if (!ip) {
|
|
85
|
+
const doConnectingIp = getHeader("do-connecting-ip");
|
|
86
|
+
if (doConnectingIp) {
|
|
87
|
+
ip = validateIP(doConnectingIp);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!ip) {
|
|
91
|
+
const realIp = getHeader("x-real-ip");
|
|
92
|
+
if (realIp) {
|
|
93
|
+
ip = validateIP(realIp);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!ip) {
|
|
97
|
+
const forwardedFor = getHeader("x-forwarded-for");
|
|
98
|
+
if (forwardedFor) {
|
|
99
|
+
const firstIp = forwardedFor.split(",")[0]?.trim();
|
|
100
|
+
ip = validateIP(firstIp);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!ip) {
|
|
104
|
+
const vercelForwarded = getHeader("x-vercel-forwarded-for");
|
|
105
|
+
if (vercelForwarded) {
|
|
106
|
+
const firstIp = vercelForwarded.split(",")[0]?.trim();
|
|
107
|
+
ip = validateIP(firstIp);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!ip) {
|
|
111
|
+
const trueClientIp = getHeader("true-client-ip");
|
|
112
|
+
if (trueClientIp) {
|
|
113
|
+
ip = validateIP(trueClientIp);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!ip && req.socket?.remoteAddress) {
|
|
117
|
+
ip = validateIP(req.socket.remoteAddress);
|
|
118
|
+
}
|
|
119
|
+
const userAgent = getHeader("user-agent");
|
|
120
|
+
const deviceFingerprint = getHeader("x-device-fingerprint");
|
|
121
|
+
const referrer = getHeader("referer");
|
|
122
|
+
return {
|
|
123
|
+
ip,
|
|
124
|
+
userAgent,
|
|
125
|
+
deviceFingerprint,
|
|
126
|
+
referrer
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function buildClientContextHeaders(context) {
|
|
130
|
+
const headers = {};
|
|
131
|
+
if (!context) {
|
|
132
|
+
return headers;
|
|
133
|
+
}
|
|
134
|
+
if (context.ip) {
|
|
135
|
+
headers["x-sm-forwarded-client-ip"] = context.ip;
|
|
136
|
+
headers["X-Client-IP"] = context.ip;
|
|
137
|
+
}
|
|
138
|
+
if (context.userAgent) {
|
|
139
|
+
headers["X-Client-User-Agent"] = context.userAgent;
|
|
140
|
+
}
|
|
141
|
+
if (context.deviceFingerprint) {
|
|
142
|
+
headers["X-Client-Device-Fingerprint"] = context.deviceFingerprint;
|
|
143
|
+
}
|
|
144
|
+
if (context.referrer) {
|
|
145
|
+
headers["X-Client-Referrer"] = context.referrer;
|
|
146
|
+
}
|
|
147
|
+
return headers;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/server/client.ts
|
|
151
|
+
var GATEWAY_URLS = {
|
|
152
|
+
dev: "https://api-dev.scalemule.com",
|
|
153
|
+
prod: "https://api.scalemule.com"
|
|
154
|
+
};
|
|
155
|
+
function resolveGatewayUrl(config) {
|
|
156
|
+
if (config.gatewayUrl) return config.gatewayUrl;
|
|
157
|
+
if (process.env.SCALEMULE_API_URL) return process.env.SCALEMULE_API_URL;
|
|
158
|
+
return GATEWAY_URLS[config.environment || "prod"];
|
|
159
|
+
}
|
|
160
|
+
var ScaleMuleServer = class {
|
|
161
|
+
constructor(config) {
|
|
162
|
+
// ==========================================================================
|
|
163
|
+
// Auth Methods
|
|
164
|
+
// ==========================================================================
|
|
165
|
+
this.auth = {
|
|
166
|
+
/**
|
|
167
|
+
* Register a new user
|
|
168
|
+
*/
|
|
169
|
+
register: async (data) => {
|
|
170
|
+
return this.request("POST", "/v1/auth/register", { body: data });
|
|
171
|
+
},
|
|
172
|
+
/**
|
|
173
|
+
* Login user - returns session token (store in HTTP-only cookie)
|
|
174
|
+
*/
|
|
175
|
+
login: async (data) => {
|
|
176
|
+
return this.request("POST", "/v1/auth/login", { body: data });
|
|
177
|
+
},
|
|
178
|
+
/**
|
|
179
|
+
* Logout user
|
|
180
|
+
*/
|
|
181
|
+
logout: async (sessionToken) => {
|
|
182
|
+
return this.request("POST", "/v1/auth/logout", {
|
|
183
|
+
body: { session_token: sessionToken }
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
/**
|
|
187
|
+
* Get current user from session token
|
|
188
|
+
*/
|
|
189
|
+
me: async (sessionToken) => {
|
|
190
|
+
return this.request("GET", "/v1/auth/me", { sessionToken });
|
|
191
|
+
},
|
|
192
|
+
/**
|
|
193
|
+
* Refresh session token
|
|
194
|
+
*/
|
|
195
|
+
refresh: async (sessionToken) => {
|
|
196
|
+
return this.request("POST", "/v1/auth/refresh", {
|
|
197
|
+
body: { session_token: sessionToken }
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
/**
|
|
201
|
+
* Request password reset email
|
|
202
|
+
*/
|
|
203
|
+
forgotPassword: async (email) => {
|
|
204
|
+
return this.request("POST", "/v1/auth/forgot-password", { body: { email } });
|
|
205
|
+
},
|
|
206
|
+
/**
|
|
207
|
+
* Reset password with token
|
|
208
|
+
*/
|
|
209
|
+
resetPassword: async (token, newPassword) => {
|
|
210
|
+
return this.request("POST", "/v1/auth/reset-password", {
|
|
211
|
+
body: { token, new_password: newPassword }
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
/**
|
|
215
|
+
* Verify email with token
|
|
216
|
+
*/
|
|
217
|
+
verifyEmail: async (token) => {
|
|
218
|
+
return this.request("POST", "/v1/auth/verify-email", { body: { token } });
|
|
219
|
+
},
|
|
220
|
+
/**
|
|
221
|
+
* Resend verification email.
|
|
222
|
+
* Can be called with a session token (authenticated) or email (unauthenticated).
|
|
223
|
+
*/
|
|
224
|
+
resendVerification: async (sessionTokenOrEmail, options) => {
|
|
225
|
+
if (options?.email) {
|
|
226
|
+
return this.request("POST", "/v1/auth/resend-verification", {
|
|
227
|
+
sessionToken: sessionTokenOrEmail,
|
|
228
|
+
body: { email: options.email }
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (sessionTokenOrEmail.includes("@")) {
|
|
232
|
+
return this.request("POST", "/v1/auth/resend-verification", {
|
|
233
|
+
body: { email: sessionTokenOrEmail }
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return this.request("POST", "/v1/auth/resend-verification", {
|
|
237
|
+
sessionToken: sessionTokenOrEmail
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
// ==========================================================================
|
|
242
|
+
// User/Profile Methods
|
|
243
|
+
// ==========================================================================
|
|
244
|
+
this.user = {
|
|
245
|
+
/**
|
|
246
|
+
* Update user profile
|
|
247
|
+
*/
|
|
248
|
+
update: async (sessionToken, data) => {
|
|
249
|
+
return this.request("PATCH", "/v1/auth/profile", {
|
|
250
|
+
sessionToken,
|
|
251
|
+
body: data
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
/**
|
|
255
|
+
* Change password
|
|
256
|
+
*/
|
|
257
|
+
changePassword: async (sessionToken, currentPassword, newPassword) => {
|
|
258
|
+
return this.request("POST", "/v1/auth/change-password", {
|
|
259
|
+
sessionToken,
|
|
260
|
+
body: { current_password: currentPassword, new_password: newPassword }
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
/**
|
|
264
|
+
* Change email
|
|
265
|
+
*/
|
|
266
|
+
changeEmail: async (sessionToken, newEmail, password) => {
|
|
267
|
+
return this.request("POST", "/v1/auth/change-email", {
|
|
268
|
+
sessionToken,
|
|
269
|
+
body: { new_email: newEmail, password }
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
/**
|
|
273
|
+
* Delete account
|
|
274
|
+
*/
|
|
275
|
+
deleteAccount: async (sessionToken, password) => {
|
|
276
|
+
return this.request("DELETE", "/v1/auth/me", {
|
|
277
|
+
sessionToken,
|
|
278
|
+
body: { password }
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
// ==========================================================================
|
|
283
|
+
// Storage/Content Methods
|
|
284
|
+
// ==========================================================================
|
|
285
|
+
// ==========================================================================
|
|
286
|
+
// Secrets Methods (Tenant Vault)
|
|
287
|
+
// ==========================================================================
|
|
288
|
+
this.secrets = {
|
|
289
|
+
/**
|
|
290
|
+
* Get a secret from the tenant vault
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```typescript
|
|
294
|
+
* const result = await scalemule.secrets.get('ANONYMOUS_USER_SALT')
|
|
295
|
+
* if (result.success) {
|
|
296
|
+
* console.log('Salt:', result.data.value)
|
|
297
|
+
* }
|
|
298
|
+
* ```
|
|
299
|
+
*/
|
|
300
|
+
get: async (key) => {
|
|
301
|
+
return this.request("GET", `/v1/vault/secrets/${encodeURIComponent(key)}`);
|
|
302
|
+
},
|
|
303
|
+
/**
|
|
304
|
+
* Set a secret in the tenant vault
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```typescript
|
|
308
|
+
* await scalemule.secrets.set('ANONYMOUS_USER_SALT', 'my-secret-salt')
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
set: async (key, value) => {
|
|
312
|
+
return this.request("PUT", `/v1/vault/secrets/${encodeURIComponent(key)}`, {
|
|
313
|
+
body: { value }
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
/**
|
|
317
|
+
* Delete a secret from the tenant vault
|
|
318
|
+
*/
|
|
319
|
+
delete: async (key) => {
|
|
320
|
+
return this.request("DELETE", `/v1/vault/secrets/${encodeURIComponent(key)}`);
|
|
321
|
+
},
|
|
322
|
+
/**
|
|
323
|
+
* List all secrets in the tenant vault
|
|
324
|
+
*/
|
|
325
|
+
list: async () => {
|
|
326
|
+
return this.request("GET", "/v1/vault/secrets");
|
|
327
|
+
},
|
|
328
|
+
/**
|
|
329
|
+
* Get secret version history
|
|
330
|
+
*/
|
|
331
|
+
versions: async (key) => {
|
|
332
|
+
return this.request(
|
|
333
|
+
"GET",
|
|
334
|
+
`/v1/vault/versions/${encodeURIComponent(key)}`
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
/**
|
|
338
|
+
* Rollback to a specific version
|
|
339
|
+
*/
|
|
340
|
+
rollback: async (key, version) => {
|
|
341
|
+
return this.request(
|
|
342
|
+
"POST",
|
|
343
|
+
`/v1/vault/actions/rollback/${encodeURIComponent(key)}`,
|
|
344
|
+
{ body: { version } }
|
|
345
|
+
);
|
|
346
|
+
},
|
|
347
|
+
/**
|
|
348
|
+
* Rotate a secret (copy current version as new version)
|
|
349
|
+
*/
|
|
350
|
+
rotate: async (key, newValue) => {
|
|
351
|
+
return this.request(
|
|
352
|
+
"POST",
|
|
353
|
+
`/v1/vault/actions/rotate/${encodeURIComponent(key)}`,
|
|
354
|
+
{ body: { value: newValue } }
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
// ==========================================================================
|
|
359
|
+
// Bundle Methods (Structured Secrets with Inheritance)
|
|
360
|
+
// ==========================================================================
|
|
361
|
+
this.bundles = {
|
|
362
|
+
/**
|
|
363
|
+
* Get a bundle (structured secret like database credentials)
|
|
364
|
+
*
|
|
365
|
+
* @param key - Bundle key (e.g., 'database/prod')
|
|
366
|
+
* @param resolve - Whether to resolve inheritance (default: true)
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* ```typescript
|
|
370
|
+
* const result = await scalemule.bundles.get('database/prod')
|
|
371
|
+
* if (result.success) {
|
|
372
|
+
* console.log('DB Host:', result.data.data.host)
|
|
373
|
+
* }
|
|
374
|
+
* ```
|
|
375
|
+
*/
|
|
376
|
+
get: async (key, resolve = true) => {
|
|
377
|
+
const params = new URLSearchParams({ resolve: resolve.toString() });
|
|
378
|
+
return this.request(
|
|
379
|
+
"GET",
|
|
380
|
+
`/v1/vault/bundles/${encodeURIComponent(key)}?${params}`
|
|
381
|
+
);
|
|
382
|
+
},
|
|
383
|
+
/**
|
|
384
|
+
* Set a bundle (structured secret)
|
|
385
|
+
*
|
|
386
|
+
* @param key - Bundle key
|
|
387
|
+
* @param type - Bundle type: 'mysql', 'postgres', 'redis', 's3', 'oauth', 'smtp', 'generic'
|
|
388
|
+
* @param data - Bundle data (structure depends on type)
|
|
389
|
+
* @param inheritsFrom - Optional parent bundle key for inheritance
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* ```typescript
|
|
393
|
+
* // Create a MySQL bundle
|
|
394
|
+
* await scalemule.bundles.set('database/prod', 'mysql', {
|
|
395
|
+
* host: 'db.example.com',
|
|
396
|
+
* port: 3306,
|
|
397
|
+
* username: 'app',
|
|
398
|
+
* password: 'secret',
|
|
399
|
+
* database: 'myapp'
|
|
400
|
+
* })
|
|
401
|
+
*
|
|
402
|
+
* // Create a bundle that inherits from another
|
|
403
|
+
* await scalemule.bundles.set('database/staging', 'mysql', {
|
|
404
|
+
* host: 'staging-db.example.com', // Override just the host
|
|
405
|
+
* }, 'database/prod')
|
|
406
|
+
* ```
|
|
407
|
+
*/
|
|
408
|
+
set: async (key, type, data, inheritsFrom) => {
|
|
409
|
+
return this.request(
|
|
410
|
+
"PUT",
|
|
411
|
+
`/v1/vault/bundles/${encodeURIComponent(key)}`,
|
|
412
|
+
{
|
|
413
|
+
body: {
|
|
414
|
+
type,
|
|
415
|
+
value: data,
|
|
416
|
+
inherits_from: inheritsFrom
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
);
|
|
420
|
+
},
|
|
421
|
+
/**
|
|
422
|
+
* Delete a bundle
|
|
423
|
+
*/
|
|
424
|
+
delete: async (key) => {
|
|
425
|
+
return this.request("DELETE", `/v1/vault/bundles/${encodeURIComponent(key)}`);
|
|
426
|
+
},
|
|
427
|
+
/**
|
|
428
|
+
* List all bundles
|
|
429
|
+
*/
|
|
430
|
+
list: async () => {
|
|
431
|
+
return this.request(
|
|
432
|
+
"GET",
|
|
433
|
+
"/v1/vault/bundles"
|
|
434
|
+
);
|
|
435
|
+
},
|
|
436
|
+
/**
|
|
437
|
+
* Get connection URL for a database bundle
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* ```typescript
|
|
441
|
+
* const result = await scalemule.bundles.connectionUrl('database/prod')
|
|
442
|
+
* if (result.success) {
|
|
443
|
+
* const client = mysql.createConnection(result.data.url)
|
|
444
|
+
* }
|
|
445
|
+
* ```
|
|
446
|
+
*/
|
|
447
|
+
connectionUrl: async (key) => {
|
|
448
|
+
return this.request(
|
|
449
|
+
"GET",
|
|
450
|
+
`/v1/vault/bundles/${encodeURIComponent(key)}?connection_url=true`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
// ==========================================================================
|
|
455
|
+
// Vault Audit Methods
|
|
456
|
+
// ==========================================================================
|
|
457
|
+
this.vaultAudit = {
|
|
458
|
+
/**
|
|
459
|
+
* Query audit logs for your tenant's vault operations
|
|
460
|
+
*
|
|
461
|
+
* @example
|
|
462
|
+
* ```typescript
|
|
463
|
+
* const result = await scalemule.vaultAudit.query({
|
|
464
|
+
* action: 'read',
|
|
465
|
+
* path: 'database/*',
|
|
466
|
+
* since: '2026-01-01'
|
|
467
|
+
* })
|
|
468
|
+
* ```
|
|
469
|
+
*/
|
|
470
|
+
query: async (options) => {
|
|
471
|
+
const params = new URLSearchParams();
|
|
472
|
+
if (options?.action) params.set("action", options.action);
|
|
473
|
+
if (options?.path) params.set("path", options.path);
|
|
474
|
+
if (options?.since) params.set("since", options.since);
|
|
475
|
+
if (options?.until) params.set("until", options.until);
|
|
476
|
+
if (options?.limit) params.set("limit", options.limit.toString());
|
|
477
|
+
const queryStr = params.toString();
|
|
478
|
+
return this.request("GET", `/v1/vault/audit${queryStr ? `?${queryStr}` : ""}`);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
this.storage = {
|
|
482
|
+
/**
|
|
483
|
+
* List user's files
|
|
484
|
+
*/
|
|
485
|
+
list: async (userId, params) => {
|
|
486
|
+
const query = new URLSearchParams();
|
|
487
|
+
if (params?.content_type) query.set("content_type", params.content_type);
|
|
488
|
+
if (params?.search) query.set("search", params.search);
|
|
489
|
+
if (params?.limit) query.set("limit", params.limit.toString());
|
|
490
|
+
if (params?.offset) query.set("offset", params.offset.toString());
|
|
491
|
+
const queryStr = query.toString();
|
|
492
|
+
const path = `/v1/storage/my-files${queryStr ? `?${queryStr}` : ""}`;
|
|
493
|
+
return this.request("GET", path, { userId });
|
|
494
|
+
},
|
|
495
|
+
/**
|
|
496
|
+
* Get file info
|
|
497
|
+
*/
|
|
498
|
+
get: async (fileId) => {
|
|
499
|
+
return this.request("GET", `/v1/storage/files/${fileId}/info`);
|
|
500
|
+
},
|
|
501
|
+
/**
|
|
502
|
+
* Delete file
|
|
503
|
+
*/
|
|
504
|
+
delete: async (userId, fileId) => {
|
|
505
|
+
return this.request("DELETE", `/v1/storage/files/${fileId}`, { userId });
|
|
506
|
+
},
|
|
507
|
+
/**
|
|
508
|
+
* Upload file (from server - use FormData)
|
|
509
|
+
*
|
|
510
|
+
* @param userId - The user ID who owns this file
|
|
511
|
+
* @param file - File data to upload
|
|
512
|
+
* @param options - Upload options
|
|
513
|
+
* @param options.clientContext - End user context to forward (IP, user agent, etc.)
|
|
514
|
+
*
|
|
515
|
+
* @example
|
|
516
|
+
* ```typescript
|
|
517
|
+
* // Forward end user context for proper attribution
|
|
518
|
+
* const result = await scalemule.storage.upload(
|
|
519
|
+
* userId,
|
|
520
|
+
* { buffer, filename, contentType },
|
|
521
|
+
* { clientContext: extractClientContext(request) }
|
|
522
|
+
* )
|
|
523
|
+
* ```
|
|
524
|
+
*/
|
|
525
|
+
upload: async (userId, file, options) => {
|
|
526
|
+
const formData = new FormData();
|
|
527
|
+
const blob = new Blob([file.buffer], { type: file.contentType });
|
|
528
|
+
formData.append("file", blob, file.filename);
|
|
529
|
+
formData.append("sm_user_id", userId);
|
|
530
|
+
const url = `${this.gatewayUrl}/v1/storage/upload`;
|
|
531
|
+
const headers = {
|
|
532
|
+
"x-api-key": this.apiKey,
|
|
533
|
+
"x-user-id": userId,
|
|
534
|
+
...buildClientContextHeaders(options?.clientContext)
|
|
535
|
+
};
|
|
536
|
+
if (this.debug && options?.clientContext) {
|
|
537
|
+
console.log(`[ScaleMule Server] Upload with client context: IP=${options.clientContext.ip}`);
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
const response = await fetch(url, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers,
|
|
543
|
+
body: formData
|
|
544
|
+
});
|
|
545
|
+
const data = await response.json();
|
|
546
|
+
if (!response.ok) {
|
|
547
|
+
return {
|
|
548
|
+
success: false,
|
|
549
|
+
error: data.error || { code: "UPLOAD_FAILED", message: "Upload failed" }
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
return data;
|
|
553
|
+
} catch (err) {
|
|
554
|
+
return {
|
|
555
|
+
success: false,
|
|
556
|
+
error: {
|
|
557
|
+
code: "UPLOAD_ERROR",
|
|
558
|
+
message: err instanceof Error ? err.message : "Upload failed"
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
// ==========================================================================
|
|
565
|
+
// Analytics Methods
|
|
566
|
+
// ==========================================================================
|
|
567
|
+
// ==========================================================================
|
|
568
|
+
// Webhooks Methods
|
|
569
|
+
// ==========================================================================
|
|
570
|
+
this.webhooks = {
|
|
571
|
+
/**
|
|
572
|
+
* Create a new webhook subscription
|
|
573
|
+
*
|
|
574
|
+
* @example
|
|
575
|
+
* ```typescript
|
|
576
|
+
* const result = await scalemule.webhooks.create({
|
|
577
|
+
* webhook_name: 'Video Status Webhook',
|
|
578
|
+
* url: 'https://myapp.com/api/webhooks/scalemule',
|
|
579
|
+
* events: ['video.ready', 'video.failed']
|
|
580
|
+
* })
|
|
581
|
+
*
|
|
582
|
+
* // Store the secret for signature verification
|
|
583
|
+
* console.log('Webhook secret:', result.data.secret)
|
|
584
|
+
* ```
|
|
585
|
+
*/
|
|
586
|
+
create: async (data) => {
|
|
587
|
+
return this.request(
|
|
588
|
+
"POST",
|
|
589
|
+
"/v1/webhooks",
|
|
590
|
+
{ body: data }
|
|
591
|
+
);
|
|
592
|
+
},
|
|
593
|
+
/**
|
|
594
|
+
* List all webhook subscriptions
|
|
595
|
+
*/
|
|
596
|
+
list: async () => {
|
|
597
|
+
return this.request("GET", "/v1/webhooks");
|
|
598
|
+
},
|
|
599
|
+
/**
|
|
600
|
+
* Delete a webhook subscription
|
|
601
|
+
*/
|
|
602
|
+
delete: async (id) => {
|
|
603
|
+
return this.request("DELETE", `/v1/webhooks/${id}`);
|
|
604
|
+
},
|
|
605
|
+
/**
|
|
606
|
+
* Update a webhook subscription
|
|
607
|
+
*/
|
|
608
|
+
update: async (id, data) => {
|
|
609
|
+
return this.request(
|
|
610
|
+
"PATCH",
|
|
611
|
+
`/v1/webhooks/${id}`,
|
|
612
|
+
{ body: data }
|
|
613
|
+
);
|
|
614
|
+
},
|
|
615
|
+
/**
|
|
616
|
+
* Get available webhook event types
|
|
617
|
+
*/
|
|
618
|
+
eventTypes: async () => {
|
|
619
|
+
return this.request("GET", "/v1/webhooks/events");
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
// ==========================================================================
|
|
623
|
+
// Analytics Methods
|
|
624
|
+
// ==========================================================================
|
|
625
|
+
this.analytics = {
|
|
626
|
+
/**
|
|
627
|
+
* Track an analytics event
|
|
628
|
+
*
|
|
629
|
+
* IMPORTANT: When calling from server-side code (API routes), always pass
|
|
630
|
+
* clientContext to ensure the real end user's IP is recorded, not the server's IP.
|
|
631
|
+
*
|
|
632
|
+
* @example
|
|
633
|
+
* ```typescript
|
|
634
|
+
* // In an API route
|
|
635
|
+
* import { extractClientContext, createServerClient } from '@scalemule/nextjs/server'
|
|
636
|
+
*
|
|
637
|
+
* export async function POST(request: NextRequest) {
|
|
638
|
+
* const clientContext = extractClientContext(request)
|
|
639
|
+
* const scalemule = createServerClient()
|
|
640
|
+
*
|
|
641
|
+
* await scalemule.analytics.trackEvent({
|
|
642
|
+
* event_name: 'button_clicked',
|
|
643
|
+
* properties: { button_id: 'signup' }
|
|
644
|
+
* }, { clientContext })
|
|
645
|
+
* }
|
|
646
|
+
* ```
|
|
647
|
+
*/
|
|
648
|
+
trackEvent: async (event, options) => {
|
|
649
|
+
return this.request("POST", "/v1/analytics/v2/events", {
|
|
650
|
+
body: event,
|
|
651
|
+
clientContext: options?.clientContext
|
|
652
|
+
});
|
|
653
|
+
},
|
|
654
|
+
/**
|
|
655
|
+
* Track a page view
|
|
656
|
+
*
|
|
657
|
+
* @example
|
|
658
|
+
* ```typescript
|
|
659
|
+
* await scalemule.analytics.trackPageView({
|
|
660
|
+
* page_url: 'https://example.com/products',
|
|
661
|
+
* page_title: 'Products',
|
|
662
|
+
* referrer: 'https://google.com'
|
|
663
|
+
* }, { clientContext })
|
|
664
|
+
* ```
|
|
665
|
+
*/
|
|
666
|
+
trackPageView: async (data, options) => {
|
|
667
|
+
return this.request("POST", "/v1/analytics/v2/events", {
|
|
668
|
+
body: {
|
|
669
|
+
event_name: "page_viewed",
|
|
670
|
+
event_category: "navigation",
|
|
671
|
+
page_url: data.page_url,
|
|
672
|
+
properties: {
|
|
673
|
+
page_title: data.page_title,
|
|
674
|
+
referrer: data.referrer
|
|
675
|
+
},
|
|
676
|
+
session_id: data.session_id,
|
|
677
|
+
user_id: data.user_id
|
|
678
|
+
},
|
|
679
|
+
clientContext: options?.clientContext
|
|
680
|
+
});
|
|
681
|
+
},
|
|
682
|
+
/**
|
|
683
|
+
* Track multiple events in a batch (max 100)
|
|
684
|
+
*
|
|
685
|
+
* @example
|
|
686
|
+
* ```typescript
|
|
687
|
+
* await scalemule.analytics.trackBatch([
|
|
688
|
+
* { event_name: 'item_viewed', properties: { item_id: '123' } },
|
|
689
|
+
* { event_name: 'item_added_to_cart', properties: { item_id: '123' } }
|
|
690
|
+
* ], { clientContext })
|
|
691
|
+
* ```
|
|
692
|
+
*/
|
|
693
|
+
trackBatch: async (events, options) => {
|
|
694
|
+
return this.request("POST", "/v1/analytics/v2/events/batch", {
|
|
695
|
+
body: { events },
|
|
696
|
+
clientContext: options?.clientContext
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
this.apiKey = config.apiKey;
|
|
701
|
+
this.gatewayUrl = resolveGatewayUrl(config);
|
|
702
|
+
this.debug = config.debug || false;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Make a request to the ScaleMule API
|
|
706
|
+
*
|
|
707
|
+
* @param method - HTTP method
|
|
708
|
+
* @param path - API path (e.g., /v1/auth/login)
|
|
709
|
+
* @param options - Request options
|
|
710
|
+
* @param options.body - Request body (will be JSON stringified)
|
|
711
|
+
* @param options.userId - User ID (passed through for storage operations)
|
|
712
|
+
* @param options.sessionToken - Session token sent as Authorization: Bearer header
|
|
713
|
+
* @param options.clientContext - End user context to forward (IP, user agent, etc.)
|
|
714
|
+
*/
|
|
715
|
+
async request(method, path, options = {}) {
|
|
716
|
+
const url = `${this.gatewayUrl}${path}`;
|
|
717
|
+
const headers = {
|
|
718
|
+
"x-api-key": this.apiKey,
|
|
719
|
+
"Content-Type": "application/json",
|
|
720
|
+
// Forward client context headers if provided
|
|
721
|
+
...buildClientContextHeaders(options.clientContext)
|
|
722
|
+
};
|
|
723
|
+
if (options.sessionToken) {
|
|
724
|
+
headers["Authorization"] = `Bearer ${options.sessionToken}`;
|
|
725
|
+
}
|
|
726
|
+
if (this.debug) {
|
|
727
|
+
console.log(`[ScaleMule Server] ${method} ${path}`);
|
|
728
|
+
if (options.clientContext) {
|
|
729
|
+
console.log(`[ScaleMule Server] Client context: IP=${options.clientContext.ip}, UA=${options.clientContext.userAgent?.substring(0, 50)}...`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const response = await fetch(url, {
|
|
734
|
+
method,
|
|
735
|
+
headers,
|
|
736
|
+
body: options.body ? JSON.stringify(options.body) : void 0
|
|
737
|
+
});
|
|
738
|
+
const data = await response.json();
|
|
739
|
+
if (!response.ok) {
|
|
740
|
+
const error = data.error || {
|
|
741
|
+
code: `HTTP_${response.status}`,
|
|
742
|
+
message: data.message || response.statusText
|
|
743
|
+
};
|
|
744
|
+
return { success: false, error };
|
|
745
|
+
}
|
|
746
|
+
return data;
|
|
747
|
+
} catch (err) {
|
|
748
|
+
return {
|
|
749
|
+
success: false,
|
|
750
|
+
error: {
|
|
751
|
+
code: "SERVER_ERROR",
|
|
752
|
+
message: err instanceof Error ? err.message : "Request failed"
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
function createServerClient(config) {
|
|
759
|
+
const apiKey = config?.apiKey || process.env.SCALEMULE_API_KEY;
|
|
760
|
+
if (!apiKey) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
"ScaleMule API key is required. Set SCALEMULE_API_KEY environment variable or pass apiKey in config."
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
const environment = config?.environment || process.env.SCALEMULE_ENV || "prod";
|
|
766
|
+
return new ScaleMuleServer({
|
|
767
|
+
apiKey,
|
|
768
|
+
environment,
|
|
769
|
+
gatewayUrl: config?.gatewayUrl,
|
|
770
|
+
debug: config?.debug || process.env.SCALEMULE_DEBUG === "true"
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
var SESSION_COOKIE_NAME = "sm_session";
|
|
774
|
+
var USER_ID_COOKIE_NAME = "sm_user_id";
|
|
775
|
+
({
|
|
776
|
+
secure: process.env.NODE_ENV === "production"});
|
|
777
|
+
function createCookieHeader(name, value, options = {}) {
|
|
778
|
+
const maxAge = options.maxAge ?? 7 * 24 * 60 * 60;
|
|
779
|
+
const secure = options.secure ?? process.env.NODE_ENV === "production";
|
|
780
|
+
const sameSite = options.sameSite ?? "lax";
|
|
781
|
+
const path = options.path ?? "/";
|
|
782
|
+
let cookie = `${name}=${encodeURIComponent(value)}; Path=${path}; Max-Age=${maxAge}; HttpOnly; SameSite=${sameSite}`;
|
|
783
|
+
if (secure) {
|
|
784
|
+
cookie += "; Secure";
|
|
785
|
+
}
|
|
786
|
+
if (options.domain) {
|
|
787
|
+
cookie += `; Domain=${options.domain}`;
|
|
788
|
+
}
|
|
789
|
+
return cookie;
|
|
790
|
+
}
|
|
791
|
+
function createClearCookieHeader(name, options = {}) {
|
|
792
|
+
const path = options.path ?? "/";
|
|
793
|
+
let cookie = `${name}=; Path=${path}; Max-Age=0; HttpOnly`;
|
|
794
|
+
if (options.domain) {
|
|
795
|
+
cookie += `; Domain=${options.domain}`;
|
|
796
|
+
}
|
|
797
|
+
return cookie;
|
|
798
|
+
}
|
|
799
|
+
function withSession(loginResponse, responseBody, options = {}) {
|
|
800
|
+
const headers = new Headers();
|
|
801
|
+
headers.set("Content-Type", "application/json");
|
|
802
|
+
headers.append(
|
|
803
|
+
"Set-Cookie",
|
|
804
|
+
createCookieHeader(SESSION_COOKIE_NAME, loginResponse.session_token, options)
|
|
805
|
+
);
|
|
806
|
+
headers.append(
|
|
807
|
+
"Set-Cookie",
|
|
808
|
+
createCookieHeader(USER_ID_COOKIE_NAME, loginResponse.user.id, options)
|
|
809
|
+
);
|
|
810
|
+
return new Response(JSON.stringify({ success: true, data: responseBody }), {
|
|
811
|
+
status: 200,
|
|
812
|
+
headers
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
function withRefreshedSession(sessionToken, userId, responseBody, options = {}) {
|
|
816
|
+
const headers = new Headers();
|
|
817
|
+
headers.set("Content-Type", "application/json");
|
|
818
|
+
headers.append(
|
|
819
|
+
"Set-Cookie",
|
|
820
|
+
createCookieHeader(SESSION_COOKIE_NAME, sessionToken, options)
|
|
821
|
+
);
|
|
822
|
+
headers.append(
|
|
823
|
+
"Set-Cookie",
|
|
824
|
+
createCookieHeader(USER_ID_COOKIE_NAME, userId, options)
|
|
825
|
+
);
|
|
826
|
+
return new Response(JSON.stringify({ success: true, data: responseBody }), {
|
|
827
|
+
status: 200,
|
|
828
|
+
headers
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
function clearSession(responseBody, options = {}, status = 200) {
|
|
832
|
+
const headers = new Headers();
|
|
833
|
+
headers.set("Content-Type", "application/json");
|
|
834
|
+
headers.append("Set-Cookie", createClearCookieHeader(SESSION_COOKIE_NAME, options));
|
|
835
|
+
headers.append("Set-Cookie", createClearCookieHeader(USER_ID_COOKIE_NAME, options));
|
|
836
|
+
return new Response(JSON.stringify({ success: status < 300, data: responseBody }), {
|
|
837
|
+
status,
|
|
838
|
+
headers
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
async function getSession() {
|
|
842
|
+
const cookieStore = await cookies();
|
|
843
|
+
const sessionCookie = cookieStore.get(SESSION_COOKIE_NAME);
|
|
844
|
+
const userIdCookie = cookieStore.get(USER_ID_COOKIE_NAME);
|
|
845
|
+
if (!sessionCookie?.value || !userIdCookie?.value) {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
sessionToken: sessionCookie.value,
|
|
850
|
+
userId: userIdCookie.value,
|
|
851
|
+
expiresAt: /* @__PURE__ */ new Date()
|
|
852
|
+
// Note: actual expiry is managed by ScaleMule backend
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
function getSessionFromRequest(request) {
|
|
856
|
+
const cookieHeader = request.headers.get("cookie");
|
|
857
|
+
if (!cookieHeader) return null;
|
|
858
|
+
const cookies4 = Object.fromEntries(
|
|
859
|
+
cookieHeader.split(";").map((c) => {
|
|
860
|
+
const [key, ...rest] = c.trim().split("=");
|
|
861
|
+
return [key, decodeURIComponent(rest.join("="))];
|
|
862
|
+
})
|
|
863
|
+
);
|
|
864
|
+
const sessionToken = cookies4[SESSION_COOKIE_NAME];
|
|
865
|
+
const userId = cookies4[USER_ID_COOKIE_NAME];
|
|
866
|
+
if (!sessionToken || !userId) {
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
sessionToken,
|
|
871
|
+
userId,
|
|
872
|
+
expiresAt: /* @__PURE__ */ new Date()
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async function requireSession() {
|
|
876
|
+
const session = await getSession();
|
|
877
|
+
if (!session) {
|
|
878
|
+
throw new Response(
|
|
879
|
+
JSON.stringify({
|
|
880
|
+
success: false,
|
|
881
|
+
error: { code: "UNAUTHORIZED", message: "Authentication required" }
|
|
882
|
+
}),
|
|
883
|
+
{
|
|
884
|
+
status: 401,
|
|
885
|
+
headers: { "Content-Type": "application/json" }
|
|
886
|
+
}
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
return session;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/server/timing.ts
|
|
893
|
+
function constantTimeEqual(a, b) {
|
|
894
|
+
const maxLength = Math.max(a.length, b.length);
|
|
895
|
+
let mismatch = a.length ^ b.length;
|
|
896
|
+
for (let i = 0; i < maxLength; i++) {
|
|
897
|
+
const aCode = i < a.length ? a.charCodeAt(i) : 0;
|
|
898
|
+
const bCode = i < b.length ? b.charCodeAt(i) : 0;
|
|
899
|
+
mismatch |= aCode ^ bCode;
|
|
900
|
+
}
|
|
901
|
+
return mismatch === 0;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/server/csrf.ts
|
|
905
|
+
var CSRF_COOKIE_NAME = "sm_csrf";
|
|
906
|
+
var CSRF_HEADER_NAME = "x-csrf-token";
|
|
907
|
+
function generateCSRFToken() {
|
|
908
|
+
const array = new Uint8Array(32);
|
|
909
|
+
crypto.getRandomValues(array);
|
|
910
|
+
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
911
|
+
}
|
|
912
|
+
function withCSRFToken(response, token) {
|
|
913
|
+
const csrfToken = token || generateCSRFToken();
|
|
914
|
+
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
|
915
|
+
httpOnly: false,
|
|
916
|
+
// Must be readable by JavaScript to include in requests
|
|
917
|
+
secure: process.env.NODE_ENV === "production",
|
|
918
|
+
sameSite: "strict",
|
|
919
|
+
path: "/",
|
|
920
|
+
maxAge: 60 * 60 * 24
|
|
921
|
+
// 24 hours
|
|
922
|
+
});
|
|
923
|
+
return response;
|
|
924
|
+
}
|
|
925
|
+
function validateCSRFToken(request) {
|
|
926
|
+
const cookieToken = request.cookies.get(CSRF_COOKIE_NAME)?.value;
|
|
927
|
+
if (!cookieToken) {
|
|
928
|
+
return "Missing CSRF cookie";
|
|
929
|
+
}
|
|
930
|
+
const headerToken = request.headers.get(CSRF_HEADER_NAME);
|
|
931
|
+
if (!headerToken) {
|
|
932
|
+
return "Missing CSRF token header";
|
|
933
|
+
}
|
|
934
|
+
if (!constantTimeEqual(cookieToken, headerToken)) {
|
|
935
|
+
return "CSRF token mismatch";
|
|
936
|
+
}
|
|
937
|
+
return void 0;
|
|
938
|
+
}
|
|
939
|
+
async function validateCSRFTokenAsync(request, body) {
|
|
940
|
+
const cookieToken = request.cookies.get(CSRF_COOKIE_NAME)?.value;
|
|
941
|
+
if (!cookieToken) {
|
|
942
|
+
return "Missing CSRF cookie";
|
|
943
|
+
}
|
|
944
|
+
let requestToken = request.headers.get(CSRF_HEADER_NAME);
|
|
945
|
+
if (!requestToken && body) {
|
|
946
|
+
requestToken = body.csrf_token ?? body._csrf ?? null;
|
|
947
|
+
}
|
|
948
|
+
if (!requestToken) {
|
|
949
|
+
return "Missing CSRF token";
|
|
950
|
+
}
|
|
951
|
+
if (!constantTimeEqual(cookieToken, requestToken)) {
|
|
952
|
+
return "CSRF token mismatch";
|
|
953
|
+
}
|
|
954
|
+
return void 0;
|
|
955
|
+
}
|
|
956
|
+
function withCSRFProtection(handler) {
|
|
957
|
+
return async (request) => {
|
|
958
|
+
if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) {
|
|
959
|
+
const error = validateCSRFToken(request);
|
|
960
|
+
if (error) {
|
|
961
|
+
return NextResponse.json(
|
|
962
|
+
{ error: "CSRF validation failed", message: error },
|
|
963
|
+
{ status: 403 }
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return handler(request);
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
async function getCSRFToken() {
|
|
971
|
+
const cookieStore = await cookies();
|
|
972
|
+
let token = cookieStore.get(CSRF_COOKIE_NAME)?.value;
|
|
973
|
+
if (!token) {
|
|
974
|
+
token = generateCSRFToken();
|
|
975
|
+
}
|
|
976
|
+
return token;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// src/server/routes.ts
|
|
980
|
+
function errorResponse(code, message, status) {
|
|
981
|
+
return new Response(
|
|
982
|
+
JSON.stringify({ success: false, error: { code, message } }),
|
|
983
|
+
{ status, headers: { "Content-Type": "application/json" } }
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
function successResponse(data, status = 200) {
|
|
987
|
+
return new Response(
|
|
988
|
+
JSON.stringify({ success: true, data }),
|
|
989
|
+
{ status, headers: { "Content-Type": "application/json" } }
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
function createAuthRoutes(config = {}) {
|
|
993
|
+
const sm = createServerClient(config.client);
|
|
994
|
+
const cookieOptions = config.cookies || {};
|
|
995
|
+
const POST = async (request, context) => {
|
|
996
|
+
if (config.csrf) {
|
|
997
|
+
const csrfError = validateCSRFToken(request);
|
|
998
|
+
if (csrfError) {
|
|
999
|
+
return errorResponse("CSRF_ERROR", "CSRF validation failed", 403);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const params = await context?.params;
|
|
1003
|
+
const path = params?.scalemule?.join("/") || "";
|
|
1004
|
+
try {
|
|
1005
|
+
const body = await request.json().catch(() => ({}));
|
|
1006
|
+
switch (path) {
|
|
1007
|
+
// ==================== Register ====================
|
|
1008
|
+
case "register": {
|
|
1009
|
+
const { email, password, full_name, username, phone } = body;
|
|
1010
|
+
if (!email || !password) {
|
|
1011
|
+
return errorResponse("VALIDATION_ERROR", "Email and password required", 400);
|
|
1012
|
+
}
|
|
1013
|
+
const result = await sm.auth.register({ email, password, full_name, username, phone });
|
|
1014
|
+
if (!result.success) {
|
|
1015
|
+
return errorResponse(
|
|
1016
|
+
result.error?.code || "REGISTER_FAILED",
|
|
1017
|
+
result.error?.message || "Registration failed",
|
|
1018
|
+
400
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
if (config.onRegister && result.data) {
|
|
1022
|
+
await config.onRegister({ id: result.data.id, email: result.data.email });
|
|
1023
|
+
}
|
|
1024
|
+
return successResponse({ user: result.data, message: "Registration successful" }, 201);
|
|
1025
|
+
}
|
|
1026
|
+
// ==================== Login ====================
|
|
1027
|
+
case "login": {
|
|
1028
|
+
const { email, password, remember_me } = body;
|
|
1029
|
+
if (!email || !password) {
|
|
1030
|
+
return errorResponse("VALIDATION_ERROR", "Email and password required", 400);
|
|
1031
|
+
}
|
|
1032
|
+
const result = await sm.auth.login({ email, password, remember_me });
|
|
1033
|
+
if (!result.success || !result.data) {
|
|
1034
|
+
const errorCode = result.error?.code || "LOGIN_FAILED";
|
|
1035
|
+
let status = 400;
|
|
1036
|
+
if (errorCode === "INVALID_CREDENTIALS" || errorCode === "UNAUTHORIZED") status = 401;
|
|
1037
|
+
if (["EMAIL_NOT_VERIFIED", "PHONE_NOT_VERIFIED", "ACCOUNT_LOCKED", "ACCOUNT_DISABLED", "MFA_REQUIRED"].includes(errorCode)) {
|
|
1038
|
+
status = 403;
|
|
1039
|
+
}
|
|
1040
|
+
return errorResponse(
|
|
1041
|
+
errorCode,
|
|
1042
|
+
result.error?.message || "Login failed",
|
|
1043
|
+
status
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
if (config.onLogin) {
|
|
1047
|
+
await config.onLogin({
|
|
1048
|
+
id: result.data.user.id,
|
|
1049
|
+
email: result.data.user.email
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
return withSession(result.data, { user: result.data.user }, cookieOptions);
|
|
1053
|
+
}
|
|
1054
|
+
// ==================== Logout ====================
|
|
1055
|
+
case "logout": {
|
|
1056
|
+
const session = await getSession();
|
|
1057
|
+
if (session) {
|
|
1058
|
+
await sm.auth.logout(session.sessionToken);
|
|
1059
|
+
}
|
|
1060
|
+
if (config.onLogout) {
|
|
1061
|
+
await config.onLogout();
|
|
1062
|
+
}
|
|
1063
|
+
return clearSession({ message: "Logged out successfully" }, cookieOptions);
|
|
1064
|
+
}
|
|
1065
|
+
// ==================== Forgot Password ====================
|
|
1066
|
+
case "forgot-password": {
|
|
1067
|
+
const { email } = body;
|
|
1068
|
+
if (!email) {
|
|
1069
|
+
return errorResponse("VALIDATION_ERROR", "Email required", 400);
|
|
1070
|
+
}
|
|
1071
|
+
const result = await sm.auth.forgotPassword(email);
|
|
1072
|
+
return successResponse({ message: "If an account exists, a reset email has been sent" });
|
|
1073
|
+
}
|
|
1074
|
+
// ==================== Reset Password ====================
|
|
1075
|
+
case "reset-password": {
|
|
1076
|
+
const { token, new_password } = body;
|
|
1077
|
+
if (!token || !new_password) {
|
|
1078
|
+
return errorResponse("VALIDATION_ERROR", "Token and new password required", 400);
|
|
1079
|
+
}
|
|
1080
|
+
const result = await sm.auth.resetPassword(token, new_password);
|
|
1081
|
+
if (!result.success) {
|
|
1082
|
+
return errorResponse(
|
|
1083
|
+
result.error?.code || "RESET_FAILED",
|
|
1084
|
+
result.error?.message || "Password reset failed",
|
|
1085
|
+
400
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
return successResponse({ message: "Password reset successful" });
|
|
1089
|
+
}
|
|
1090
|
+
// ==================== Verify Email ====================
|
|
1091
|
+
case "verify-email": {
|
|
1092
|
+
const { token } = body;
|
|
1093
|
+
if (!token) {
|
|
1094
|
+
return errorResponse("VALIDATION_ERROR", "Token required", 400);
|
|
1095
|
+
}
|
|
1096
|
+
const result = await sm.auth.verifyEmail(token);
|
|
1097
|
+
if (!result.success) {
|
|
1098
|
+
return errorResponse(
|
|
1099
|
+
result.error?.code || "VERIFY_FAILED",
|
|
1100
|
+
result.error?.message || "Email verification failed",
|
|
1101
|
+
400
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
return successResponse({ message: "Email verified successfully" });
|
|
1105
|
+
}
|
|
1106
|
+
// ==================== Resend Verification ====================
|
|
1107
|
+
// Supports both authenticated (session-based) and unauthenticated (email-based) resend
|
|
1108
|
+
case "resend-verification": {
|
|
1109
|
+
const { email } = body;
|
|
1110
|
+
const session = await getSession();
|
|
1111
|
+
if (email) {
|
|
1112
|
+
const result2 = await sm.auth.resendVerification(email);
|
|
1113
|
+
if (!result2.success) {
|
|
1114
|
+
return errorResponse(
|
|
1115
|
+
result2.error?.code || "RESEND_FAILED",
|
|
1116
|
+
result2.error?.message || "Failed to resend verification",
|
|
1117
|
+
result2.error?.code === "RATE_LIMITED" ? 429 : 400
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
return successResponse({ message: "Verification email sent" });
|
|
1121
|
+
}
|
|
1122
|
+
if (!session) {
|
|
1123
|
+
return errorResponse("UNAUTHORIZED", "Email or session required", 401);
|
|
1124
|
+
}
|
|
1125
|
+
const result = await sm.auth.resendVerification(session.sessionToken);
|
|
1126
|
+
if (!result.success) {
|
|
1127
|
+
return errorResponse(
|
|
1128
|
+
result.error?.code || "RESEND_FAILED",
|
|
1129
|
+
result.error?.message || "Failed to resend verification",
|
|
1130
|
+
400
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
return successResponse({ message: "Verification email sent" });
|
|
1134
|
+
}
|
|
1135
|
+
// ==================== Refresh Session ====================
|
|
1136
|
+
case "refresh": {
|
|
1137
|
+
const session = await getSession();
|
|
1138
|
+
if (!session) {
|
|
1139
|
+
return errorResponse("UNAUTHORIZED", "Authentication required", 401);
|
|
1140
|
+
}
|
|
1141
|
+
const result = await sm.auth.refresh(session.sessionToken);
|
|
1142
|
+
if (!result.success || !result.data) {
|
|
1143
|
+
return clearSession(
|
|
1144
|
+
{ message: "Session expired" },
|
|
1145
|
+
cookieOptions
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
return withRefreshedSession(
|
|
1149
|
+
result.data.session_token,
|
|
1150
|
+
session.userId,
|
|
1151
|
+
{ message: "Session refreshed" },
|
|
1152
|
+
cookieOptions
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
// ==================== Change Password ====================
|
|
1156
|
+
case "change-password": {
|
|
1157
|
+
const session = await getSession();
|
|
1158
|
+
if (!session) {
|
|
1159
|
+
return errorResponse("UNAUTHORIZED", "Authentication required", 401);
|
|
1160
|
+
}
|
|
1161
|
+
const { current_password, new_password } = body;
|
|
1162
|
+
if (!current_password || !new_password) {
|
|
1163
|
+
return errorResponse("VALIDATION_ERROR", "Current and new password required", 400);
|
|
1164
|
+
}
|
|
1165
|
+
const result = await sm.user.changePassword(
|
|
1166
|
+
session.sessionToken,
|
|
1167
|
+
current_password,
|
|
1168
|
+
new_password
|
|
1169
|
+
);
|
|
1170
|
+
if (!result.success) {
|
|
1171
|
+
return errorResponse(
|
|
1172
|
+
result.error?.code || "CHANGE_FAILED",
|
|
1173
|
+
result.error?.message || "Failed to change password",
|
|
1174
|
+
400
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
return successResponse({ message: "Password changed successfully" });
|
|
1178
|
+
}
|
|
1179
|
+
default:
|
|
1180
|
+
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1181
|
+
}
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
console.error("[ScaleMule Auth] Error:", err);
|
|
1184
|
+
return errorResponse("SERVER_ERROR", "Internal server error", 500);
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
const GET = async (request, context) => {
|
|
1188
|
+
const params = await context?.params;
|
|
1189
|
+
const path = params?.scalemule?.join("/") || "";
|
|
1190
|
+
try {
|
|
1191
|
+
switch (path) {
|
|
1192
|
+
// ==================== Get Current User ====================
|
|
1193
|
+
case "me": {
|
|
1194
|
+
const session = await getSession();
|
|
1195
|
+
if (!session) {
|
|
1196
|
+
return errorResponse("UNAUTHORIZED", "Authentication required", 401);
|
|
1197
|
+
}
|
|
1198
|
+
const result = await sm.auth.me(session.sessionToken);
|
|
1199
|
+
if (!result.success || !result.data) {
|
|
1200
|
+
return clearSession(
|
|
1201
|
+
{ error: { code: "SESSION_EXPIRED", message: "Session expired" } },
|
|
1202
|
+
cookieOptions
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
return successResponse({ user: result.data });
|
|
1206
|
+
}
|
|
1207
|
+
// ==================== Get Session Status ====================
|
|
1208
|
+
case "session": {
|
|
1209
|
+
const session = await getSession();
|
|
1210
|
+
return successResponse({
|
|
1211
|
+
authenticated: !!session,
|
|
1212
|
+
userId: session?.userId || null
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
default:
|
|
1216
|
+
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1217
|
+
}
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
console.error("[ScaleMule Auth] Error:", err);
|
|
1220
|
+
return errorResponse("SERVER_ERROR", "Internal server error", 500);
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
const DELETE = async (request, context) => {
|
|
1224
|
+
const params = await context?.params;
|
|
1225
|
+
const path = params?.scalemule?.join("/") || "";
|
|
1226
|
+
try {
|
|
1227
|
+
switch (path) {
|
|
1228
|
+
// ==================== Delete Account ====================
|
|
1229
|
+
case "me":
|
|
1230
|
+
case "account": {
|
|
1231
|
+
const session = await getSession();
|
|
1232
|
+
if (!session) {
|
|
1233
|
+
return errorResponse("UNAUTHORIZED", "Authentication required", 401);
|
|
1234
|
+
}
|
|
1235
|
+
const body = await request.json().catch(() => ({}));
|
|
1236
|
+
const { password } = body;
|
|
1237
|
+
if (!password) {
|
|
1238
|
+
return errorResponse("VALIDATION_ERROR", "Password required", 400);
|
|
1239
|
+
}
|
|
1240
|
+
const result = await sm.user.deleteAccount(session.sessionToken, password);
|
|
1241
|
+
if (!result.success) {
|
|
1242
|
+
return errorResponse(
|
|
1243
|
+
result.error?.code || "DELETE_FAILED",
|
|
1244
|
+
result.error?.message || "Failed to delete account",
|
|
1245
|
+
400
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
return clearSession({ message: "Account deleted successfully" }, cookieOptions);
|
|
1249
|
+
}
|
|
1250
|
+
default:
|
|
1251
|
+
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1252
|
+
}
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
console.error("[ScaleMule Auth] Error:", err);
|
|
1255
|
+
return errorResponse("SERVER_ERROR", "Internal server error", 500);
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
const PATCH = async (request, context) => {
|
|
1259
|
+
const params = await context?.params;
|
|
1260
|
+
const path = params?.scalemule?.join("/") || "";
|
|
1261
|
+
try {
|
|
1262
|
+
switch (path) {
|
|
1263
|
+
// ==================== Update Profile ====================
|
|
1264
|
+
case "me":
|
|
1265
|
+
case "profile": {
|
|
1266
|
+
const session = await getSession();
|
|
1267
|
+
if (!session) {
|
|
1268
|
+
return errorResponse("UNAUTHORIZED", "Authentication required", 401);
|
|
1269
|
+
}
|
|
1270
|
+
const body = await request.json().catch(() => ({}));
|
|
1271
|
+
const { full_name, avatar_url } = body;
|
|
1272
|
+
const result = await sm.user.update(session.sessionToken, { full_name, avatar_url });
|
|
1273
|
+
if (!result.success || !result.data) {
|
|
1274
|
+
return errorResponse(
|
|
1275
|
+
result.error?.code || "UPDATE_FAILED",
|
|
1276
|
+
result.error?.message || "Failed to update profile",
|
|
1277
|
+
400
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
return successResponse({ user: result.data });
|
|
1281
|
+
}
|
|
1282
|
+
default:
|
|
1283
|
+
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1284
|
+
}
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
console.error("[ScaleMule Auth] Error:", err);
|
|
1287
|
+
return errorResponse("SERVER_ERROR", "Internal server error", 500);
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
return { GET, POST, DELETE, PATCH };
|
|
1291
|
+
}
|
|
1292
|
+
function createAnalyticsRoutes(config = {}) {
|
|
1293
|
+
const sm = createServerClient(config.client);
|
|
1294
|
+
const handleTrackEvent = async (body, clientContext) => {
|
|
1295
|
+
const {
|
|
1296
|
+
event_name,
|
|
1297
|
+
event_category,
|
|
1298
|
+
properties,
|
|
1299
|
+
user_id,
|
|
1300
|
+
session_id,
|
|
1301
|
+
anonymous_id,
|
|
1302
|
+
session_duration_seconds,
|
|
1303
|
+
page_url,
|
|
1304
|
+
page_title,
|
|
1305
|
+
referrer,
|
|
1306
|
+
landing_page,
|
|
1307
|
+
device_type,
|
|
1308
|
+
device_brand,
|
|
1309
|
+
device_model,
|
|
1310
|
+
browser,
|
|
1311
|
+
browser_version,
|
|
1312
|
+
os,
|
|
1313
|
+
os_version,
|
|
1314
|
+
screen_resolution,
|
|
1315
|
+
viewport_size,
|
|
1316
|
+
utm_source,
|
|
1317
|
+
utm_medium,
|
|
1318
|
+
utm_campaign,
|
|
1319
|
+
utm_term,
|
|
1320
|
+
utm_content,
|
|
1321
|
+
client_timestamp,
|
|
1322
|
+
timestamp
|
|
1323
|
+
// Legacy field
|
|
1324
|
+
} = body;
|
|
1325
|
+
if (!event_name) {
|
|
1326
|
+
return errorResponse("VALIDATION_ERROR", "event_name is required", 400);
|
|
1327
|
+
}
|
|
1328
|
+
const result = await sm.analytics.trackEvent(
|
|
1329
|
+
{
|
|
1330
|
+
event_name,
|
|
1331
|
+
event_category,
|
|
1332
|
+
properties,
|
|
1333
|
+
user_id,
|
|
1334
|
+
session_id,
|
|
1335
|
+
anonymous_id,
|
|
1336
|
+
session_duration_seconds,
|
|
1337
|
+
page_url,
|
|
1338
|
+
page_title,
|
|
1339
|
+
referrer,
|
|
1340
|
+
landing_page,
|
|
1341
|
+
device_type,
|
|
1342
|
+
device_brand,
|
|
1343
|
+
device_model,
|
|
1344
|
+
browser,
|
|
1345
|
+
browser_version,
|
|
1346
|
+
os,
|
|
1347
|
+
os_version,
|
|
1348
|
+
screen_resolution,
|
|
1349
|
+
viewport_size,
|
|
1350
|
+
utm_source,
|
|
1351
|
+
utm_medium,
|
|
1352
|
+
utm_campaign,
|
|
1353
|
+
utm_term,
|
|
1354
|
+
utm_content,
|
|
1355
|
+
client_timestamp: client_timestamp || timestamp
|
|
1356
|
+
},
|
|
1357
|
+
{ clientContext }
|
|
1358
|
+
);
|
|
1359
|
+
if (!result.success) {
|
|
1360
|
+
return errorResponse(
|
|
1361
|
+
result.error?.code || "TRACK_FAILED",
|
|
1362
|
+
result.error?.message || "Failed to track event",
|
|
1363
|
+
400
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
if (config.onEvent) {
|
|
1367
|
+
await config.onEvent({ event_name, session_id: result.data?.session_id });
|
|
1368
|
+
}
|
|
1369
|
+
return successResponse({ tracked: result.data?.tracked || 1, session_id: result.data?.session_id });
|
|
1370
|
+
};
|
|
1371
|
+
const POST = async (request, context) => {
|
|
1372
|
+
try {
|
|
1373
|
+
const body = await request.json().catch(() => ({}));
|
|
1374
|
+
const clientContext = extractClientContext(request);
|
|
1375
|
+
if (config.simpleProxy) {
|
|
1376
|
+
return handleTrackEvent(body, clientContext);
|
|
1377
|
+
}
|
|
1378
|
+
const params = await context?.params;
|
|
1379
|
+
const path = params?.scalemule?.join("/") || "";
|
|
1380
|
+
switch (path) {
|
|
1381
|
+
// ==================== Track Single Event ====================
|
|
1382
|
+
case "event":
|
|
1383
|
+
case "events":
|
|
1384
|
+
case "": {
|
|
1385
|
+
return handleTrackEvent(body, clientContext);
|
|
1386
|
+
}
|
|
1387
|
+
// ==================== Track Batch Events ====================
|
|
1388
|
+
case "batch": {
|
|
1389
|
+
const { events } = body;
|
|
1390
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
1391
|
+
return errorResponse("VALIDATION_ERROR", "events array is required", 400);
|
|
1392
|
+
}
|
|
1393
|
+
if (events.length > 100) {
|
|
1394
|
+
return errorResponse("VALIDATION_ERROR", "Maximum 100 events per batch", 400);
|
|
1395
|
+
}
|
|
1396
|
+
const result = await sm.analytics.trackBatch(events, { clientContext });
|
|
1397
|
+
if (!result.success) {
|
|
1398
|
+
return errorResponse(
|
|
1399
|
+
result.error?.code || "BATCH_FAILED",
|
|
1400
|
+
result.error?.message || "Failed to track events",
|
|
1401
|
+
400
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
return successResponse({ tracked: result.data?.tracked || events.length });
|
|
1405
|
+
}
|
|
1406
|
+
// ==================== Track Page View ====================
|
|
1407
|
+
case "page-view":
|
|
1408
|
+
case "pageview": {
|
|
1409
|
+
const { page_url, page_title, referrer, session_id, user_id } = body;
|
|
1410
|
+
if (!page_url) {
|
|
1411
|
+
return errorResponse("VALIDATION_ERROR", "page_url is required", 400);
|
|
1412
|
+
}
|
|
1413
|
+
const result = await sm.analytics.trackPageView(
|
|
1414
|
+
{ page_url, page_title, referrer, session_id, user_id },
|
|
1415
|
+
{ clientContext }
|
|
1416
|
+
);
|
|
1417
|
+
if (!result.success) {
|
|
1418
|
+
return errorResponse(
|
|
1419
|
+
result.error?.code || "TRACK_FAILED",
|
|
1420
|
+
result.error?.message || "Failed to track page view",
|
|
1421
|
+
400
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
if (config.onEvent) {
|
|
1425
|
+
await config.onEvent({ event_name: "page_viewed", session_id: result.data?.session_id });
|
|
1426
|
+
}
|
|
1427
|
+
return successResponse({ tracked: result.data?.tracked || 1, session_id: result.data?.session_id });
|
|
1428
|
+
}
|
|
1429
|
+
default:
|
|
1430
|
+
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1431
|
+
}
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
console.error("[ScaleMule Analytics] Error:", err);
|
|
1434
|
+
return successResponse({ tracked: 0 });
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
return { POST };
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// src/server/errors.ts
|
|
1441
|
+
var ScaleMuleError = class extends Error {
|
|
1442
|
+
constructor(code, message, status = 400, details) {
|
|
1443
|
+
super(message);
|
|
1444
|
+
this.code = code;
|
|
1445
|
+
this.status = status;
|
|
1446
|
+
this.details = details;
|
|
1447
|
+
this.name = "ScaleMuleError";
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
var CODE_TO_STATUS = {
|
|
1451
|
+
// Auth (401)
|
|
1452
|
+
unauthorized: 401,
|
|
1453
|
+
invalid_credentials: 401,
|
|
1454
|
+
session_expired: 401,
|
|
1455
|
+
token_expired: 401,
|
|
1456
|
+
token_invalid: 401,
|
|
1457
|
+
// Forbidden (403)
|
|
1458
|
+
forbidden: 403,
|
|
1459
|
+
email_not_verified: 403,
|
|
1460
|
+
phone_not_verified: 403,
|
|
1461
|
+
account_locked: 403,
|
|
1462
|
+
account_disabled: 403,
|
|
1463
|
+
mfa_required: 403,
|
|
1464
|
+
csrf_error: 403,
|
|
1465
|
+
origin_not_allowed: 403,
|
|
1466
|
+
// Not found (404)
|
|
1467
|
+
not_found: 404,
|
|
1468
|
+
// Conflict (409)
|
|
1469
|
+
conflict: 409,
|
|
1470
|
+
email_taken: 409,
|
|
1471
|
+
// Rate limiting (429)
|
|
1472
|
+
rate_limited: 429,
|
|
1473
|
+
quota_exceeded: 429,
|
|
1474
|
+
// Validation (400)
|
|
1475
|
+
validation_error: 400,
|
|
1476
|
+
weak_password: 400,
|
|
1477
|
+
invalid_email: 400,
|
|
1478
|
+
invalid_otp: 400,
|
|
1479
|
+
otp_expired: 400,
|
|
1480
|
+
// Server (500)
|
|
1481
|
+
internal_error: 500,
|
|
1482
|
+
// Network — SDK-generated (502/504)
|
|
1483
|
+
network_error: 502,
|
|
1484
|
+
timeout: 504
|
|
1485
|
+
};
|
|
1486
|
+
function errorCodeToStatus(code) {
|
|
1487
|
+
return CODE_TO_STATUS[code.toLowerCase()] || 400;
|
|
1488
|
+
}
|
|
1489
|
+
function unwrap(result) {
|
|
1490
|
+
if (result.error || result.success === false) {
|
|
1491
|
+
const err = result.error;
|
|
1492
|
+
const code = err?.code || "UNKNOWN_ERROR";
|
|
1493
|
+
const status = err?.status || errorCodeToStatus(code);
|
|
1494
|
+
throw new ScaleMuleError(
|
|
1495
|
+
code,
|
|
1496
|
+
err?.message || "An error occurred",
|
|
1497
|
+
status,
|
|
1498
|
+
err?.details
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
return result.data;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// src/server/handler.ts
|
|
1505
|
+
function apiHandler(handler, options) {
|
|
1506
|
+
return async (request, routeContext) => {
|
|
1507
|
+
try {
|
|
1508
|
+
if (options?.csrf) {
|
|
1509
|
+
const csrfError = validateCSRFToken(request);
|
|
1510
|
+
if (csrfError) {
|
|
1511
|
+
throw new ScaleMuleError("CSRF_ERROR", csrfError, 403);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
let session;
|
|
1515
|
+
if (options?.auth) {
|
|
1516
|
+
session = await requireSession();
|
|
1517
|
+
}
|
|
1518
|
+
const rawParams = routeContext?.params ? await routeContext.params : {};
|
|
1519
|
+
const params = {};
|
|
1520
|
+
for (const [key, val] of Object.entries(rawParams)) {
|
|
1521
|
+
params[key] = Array.isArray(val) ? val.join("/") : val;
|
|
1522
|
+
}
|
|
1523
|
+
const context = {
|
|
1524
|
+
params,
|
|
1525
|
+
searchParams: request.nextUrl.searchParams,
|
|
1526
|
+
session
|
|
1527
|
+
};
|
|
1528
|
+
const result = await handler(request, context);
|
|
1529
|
+
if (result instanceof Response) return result;
|
|
1530
|
+
if (result !== void 0) {
|
|
1531
|
+
return Response.json({ success: true, data: result }, { status: 200 });
|
|
1532
|
+
}
|
|
1533
|
+
return new Response(null, { status: 204 });
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
if (error instanceof ScaleMuleError) {
|
|
1536
|
+
if (options?.onError) {
|
|
1537
|
+
const custom = options.onError(error);
|
|
1538
|
+
if (custom) return custom;
|
|
1539
|
+
}
|
|
1540
|
+
return Response.json(
|
|
1541
|
+
{ success: false, error: { code: error.code, message: error.message } },
|
|
1542
|
+
{ status: error.status }
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
if (error instanceof Response) return error;
|
|
1546
|
+
console.error("Unhandled API error:", error);
|
|
1547
|
+
return Response.json(
|
|
1548
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" } },
|
|
1549
|
+
{ status: 500 }
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
function verifyWebhookSignature(payload, signature, secret) {
|
|
1555
|
+
if (!signature.startsWith("sha256=")) {
|
|
1556
|
+
return false;
|
|
1557
|
+
}
|
|
1558
|
+
const providedSig = signature.slice(7);
|
|
1559
|
+
const expectedSig = createHmac("sha256", secret).update(payload).digest("hex");
|
|
1560
|
+
try {
|
|
1561
|
+
return timingSafeEqual(
|
|
1562
|
+
Buffer.from(providedSig, "hex"),
|
|
1563
|
+
Buffer.from(expectedSig, "hex")
|
|
1564
|
+
);
|
|
1565
|
+
} catch {
|
|
1566
|
+
return false;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
function parseWebhookEvent(payload) {
|
|
1570
|
+
return JSON.parse(payload);
|
|
1571
|
+
}
|
|
1572
|
+
async function registerVideoWebhook(url, options) {
|
|
1573
|
+
const sm = createServerClient(options?.clientConfig);
|
|
1574
|
+
const result = await sm.webhooks.create({
|
|
1575
|
+
webhook_name: options?.name || "Video Status Webhook",
|
|
1576
|
+
url,
|
|
1577
|
+
events: options?.events || ["video.ready", "video.failed"]
|
|
1578
|
+
});
|
|
1579
|
+
if (!result.success || !result.data) {
|
|
1580
|
+
throw new Error(result.error?.message || "Failed to register webhook");
|
|
1581
|
+
}
|
|
1582
|
+
return {
|
|
1583
|
+
id: result.data.id,
|
|
1584
|
+
secret: result.data.secret
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
function createWebhookRoutes(config = {}) {
|
|
1588
|
+
const POST = async (request) => {
|
|
1589
|
+
const signature = request.headers.get("x-webhook-signature");
|
|
1590
|
+
const body = await request.text();
|
|
1591
|
+
if (config.secret) {
|
|
1592
|
+
if (!signature || !verifyWebhookSignature(body, signature, config.secret)) {
|
|
1593
|
+
return new Response(JSON.stringify({ error: "Invalid signature" }), {
|
|
1594
|
+
status: 401,
|
|
1595
|
+
headers: { "Content-Type": "application/json" }
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
try {
|
|
1600
|
+
const event = parseWebhookEvent(body);
|
|
1601
|
+
switch (event.event) {
|
|
1602
|
+
case "video.ready":
|
|
1603
|
+
if (config.onVideoReady) {
|
|
1604
|
+
await config.onVideoReady(event.data);
|
|
1605
|
+
}
|
|
1606
|
+
break;
|
|
1607
|
+
case "video.failed":
|
|
1608
|
+
if (config.onVideoFailed) {
|
|
1609
|
+
await config.onVideoFailed(event.data);
|
|
1610
|
+
}
|
|
1611
|
+
break;
|
|
1612
|
+
case "video.uploaded":
|
|
1613
|
+
if (config.onVideoUploaded) {
|
|
1614
|
+
await config.onVideoUploaded(event.data);
|
|
1615
|
+
}
|
|
1616
|
+
break;
|
|
1617
|
+
case "video.transcoded":
|
|
1618
|
+
if (config.onVideoTranscoded) {
|
|
1619
|
+
await config.onVideoTranscoded(event.data);
|
|
1620
|
+
}
|
|
1621
|
+
break;
|
|
1622
|
+
}
|
|
1623
|
+
if (config.onEvent) {
|
|
1624
|
+
await config.onEvent(event);
|
|
1625
|
+
}
|
|
1626
|
+
return new Response(JSON.stringify({ received: true }), {
|
|
1627
|
+
status: 200,
|
|
1628
|
+
headers: { "Content-Type": "application/json" }
|
|
1629
|
+
});
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
console.error("Webhook handler error:", error);
|
|
1632
|
+
return new Response(JSON.stringify({ error: "Handler failed" }), {
|
|
1633
|
+
status: 500,
|
|
1634
|
+
headers: { "Content-Type": "application/json" }
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
return { POST };
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// src/server/webhook-handler.ts
|
|
1642
|
+
function createWebhookHandler(config = {}) {
|
|
1643
|
+
return async (request) => {
|
|
1644
|
+
const signature = request.headers.get("x-webhook-signature");
|
|
1645
|
+
const body = await request.text();
|
|
1646
|
+
if (config.secret) {
|
|
1647
|
+
if (!signature || !verifyWebhookSignature(body, signature, config.secret)) {
|
|
1648
|
+
return new Response(JSON.stringify({ error: "Invalid signature" }), {
|
|
1649
|
+
status: 401,
|
|
1650
|
+
headers: { "Content-Type": "application/json" }
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
try {
|
|
1655
|
+
const event = parseWebhookEvent(body);
|
|
1656
|
+
if (config.onEvent && event.event && config.onEvent[event.event]) {
|
|
1657
|
+
await config.onEvent[event.event](event);
|
|
1658
|
+
}
|
|
1659
|
+
return new Response(JSON.stringify({ received: true }), {
|
|
1660
|
+
status: 200,
|
|
1661
|
+
headers: { "Content-Type": "application/json" }
|
|
1662
|
+
});
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
return new Response(JSON.stringify({ error: "Webhook processing failed" }), {
|
|
1665
|
+
status: 500,
|
|
1666
|
+
headers: { "Content-Type": "application/json" }
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
function globToRegex(pattern) {
|
|
1672
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
1673
|
+
return new RegExp(`^${escaped}$`);
|
|
1674
|
+
}
|
|
1675
|
+
function matchesPattern(pathname, patterns) {
|
|
1676
|
+
return patterns.some((pattern) => {
|
|
1677
|
+
if (pattern === pathname) return true;
|
|
1678
|
+
if (pattern.includes("*") || pattern.includes("?")) {
|
|
1679
|
+
return globToRegex(pattern).test(pathname);
|
|
1680
|
+
}
|
|
1681
|
+
if (pathname.startsWith(pattern + "/")) return true;
|
|
1682
|
+
return false;
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
function createAuthMiddleware(config = {}) {
|
|
1686
|
+
const {
|
|
1687
|
+
protectedRoutes = [],
|
|
1688
|
+
publicRoutes = [],
|
|
1689
|
+
authOnlyPublic = [],
|
|
1690
|
+
redirectTo = "/login",
|
|
1691
|
+
redirectAuthenticated,
|
|
1692
|
+
skipValidation = false,
|
|
1693
|
+
onUnauthorized
|
|
1694
|
+
} = config;
|
|
1695
|
+
return async function middleware(request) {
|
|
1696
|
+
const { pathname } = request.nextUrl;
|
|
1697
|
+
if (pathname.startsWith("/api/auth")) {
|
|
1698
|
+
return NextResponse.next();
|
|
1699
|
+
}
|
|
1700
|
+
if (publicRoutes.length > 0 && matchesPattern(pathname, publicRoutes)) {
|
|
1701
|
+
if (redirectAuthenticated && authOnlyPublic.length > 0 && matchesPattern(pathname, authOnlyPublic)) {
|
|
1702
|
+
const session2 = getSessionFromRequest(request);
|
|
1703
|
+
if (session2) {
|
|
1704
|
+
return NextResponse.redirect(new URL(redirectAuthenticated, request.url));
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
return NextResponse.next();
|
|
1708
|
+
}
|
|
1709
|
+
const requiresAuth = protectedRoutes.length === 0 || matchesPattern(pathname, protectedRoutes);
|
|
1710
|
+
if (!requiresAuth) {
|
|
1711
|
+
return NextResponse.next();
|
|
1712
|
+
}
|
|
1713
|
+
const session = getSessionFromRequest(request);
|
|
1714
|
+
if (!session) {
|
|
1715
|
+
if (onUnauthorized) {
|
|
1716
|
+
return onUnauthorized(request);
|
|
1717
|
+
}
|
|
1718
|
+
const redirectUrl = new URL(redirectTo, request.url);
|
|
1719
|
+
redirectUrl.searchParams.set("callbackUrl", pathname);
|
|
1720
|
+
return NextResponse.redirect(redirectUrl);
|
|
1721
|
+
}
|
|
1722
|
+
if (!skipValidation) {
|
|
1723
|
+
try {
|
|
1724
|
+
const sm = createServerClient();
|
|
1725
|
+
const result = await sm.auth.me(session.sessionToken);
|
|
1726
|
+
if (!result.success) {
|
|
1727
|
+
const response = NextResponse.redirect(new URL(redirectTo, request.url));
|
|
1728
|
+
response.cookies.delete(SESSION_COOKIE_NAME);
|
|
1729
|
+
response.cookies.delete(USER_ID_COOKIE_NAME);
|
|
1730
|
+
return response;
|
|
1731
|
+
}
|
|
1732
|
+
} catch (error) {
|
|
1733
|
+
console.error("[ScaleMule Middleware] Session validation failed, blocking request:", error);
|
|
1734
|
+
const response = NextResponse.redirect(new URL(redirectTo, request.url));
|
|
1735
|
+
response.cookies.delete(SESSION_COOKIE_NAME);
|
|
1736
|
+
response.cookies.delete(USER_ID_COOKIE_NAME);
|
|
1737
|
+
return response;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
return NextResponse.next();
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
function withAuth(config = {}) {
|
|
1744
|
+
const { redirectTo = "/login", onUnauthorized } = config;
|
|
1745
|
+
return function middleware(request) {
|
|
1746
|
+
const session = getSessionFromRequest(request);
|
|
1747
|
+
if (!session) {
|
|
1748
|
+
if (onUnauthorized) {
|
|
1749
|
+
return onUnauthorized(request);
|
|
1750
|
+
}
|
|
1751
|
+
const redirectUrl = new URL(redirectTo, request.url);
|
|
1752
|
+
redirectUrl.searchParams.set("callbackUrl", request.nextUrl.pathname);
|
|
1753
|
+
return NextResponse.redirect(redirectUrl);
|
|
1754
|
+
}
|
|
1755
|
+
return NextResponse.next();
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
var OAUTH_STATE_COOKIE_NAME = "sm_oauth_state";
|
|
1759
|
+
function setOAuthState(response, state) {
|
|
1760
|
+
response.cookies.set(OAUTH_STATE_COOKIE_NAME, state, {
|
|
1761
|
+
httpOnly: true,
|
|
1762
|
+
secure: process.env.NODE_ENV === "production",
|
|
1763
|
+
sameSite: "lax",
|
|
1764
|
+
// Lax allows the cookie to be sent on OAuth redirects
|
|
1765
|
+
path: "/",
|
|
1766
|
+
maxAge: 60 * 10
|
|
1767
|
+
// 10 minutes - OAuth flows should complete quickly
|
|
1768
|
+
});
|
|
1769
|
+
return response;
|
|
1770
|
+
}
|
|
1771
|
+
function validateOAuthState(request, callbackState) {
|
|
1772
|
+
const cookieState = request.cookies.get(OAUTH_STATE_COOKIE_NAME)?.value;
|
|
1773
|
+
if (!cookieState) {
|
|
1774
|
+
return "Missing OAuth state cookie - session may have expired";
|
|
1775
|
+
}
|
|
1776
|
+
if (!callbackState) {
|
|
1777
|
+
return "Missing OAuth state in callback";
|
|
1778
|
+
}
|
|
1779
|
+
if (!constantTimeEqual(cookieState, callbackState)) {
|
|
1780
|
+
return "OAuth state mismatch - possible CSRF attack";
|
|
1781
|
+
}
|
|
1782
|
+
return void 0;
|
|
1783
|
+
}
|
|
1784
|
+
async function validateOAuthStateAsync(callbackState) {
|
|
1785
|
+
const cookieStore = await cookies();
|
|
1786
|
+
const cookieState = cookieStore.get(OAUTH_STATE_COOKIE_NAME)?.value;
|
|
1787
|
+
if (!cookieState) {
|
|
1788
|
+
return "Missing OAuth state cookie - session may have expired";
|
|
1789
|
+
}
|
|
1790
|
+
if (!callbackState) {
|
|
1791
|
+
return "Missing OAuth state in callback";
|
|
1792
|
+
}
|
|
1793
|
+
if (!constantTimeEqual(cookieState, callbackState)) {
|
|
1794
|
+
return "OAuth state mismatch - possible CSRF attack";
|
|
1795
|
+
}
|
|
1796
|
+
return void 0;
|
|
1797
|
+
}
|
|
1798
|
+
function clearOAuthState(response) {
|
|
1799
|
+
response.cookies.delete(OAUTH_STATE_COOKIE_NAME);
|
|
1800
|
+
return response;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// src/server/secrets.ts
|
|
1804
|
+
var DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
1805
|
+
var secretsCache = {};
|
|
1806
|
+
var globalConfig = {};
|
|
1807
|
+
function configureSecrets(config) {
|
|
1808
|
+
globalConfig = { ...globalConfig, ...config };
|
|
1809
|
+
}
|
|
1810
|
+
async function getAppSecret(key) {
|
|
1811
|
+
const cacheTtl = globalConfig.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
1812
|
+
const noCache = globalConfig.noCache ?? false;
|
|
1813
|
+
if (!noCache) {
|
|
1814
|
+
const cached = secretsCache[key];
|
|
1815
|
+
if (cached && Date.now() - cached.cachedAt < cacheTtl) {
|
|
1816
|
+
return cached.value;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
try {
|
|
1820
|
+
const client = createServerClient();
|
|
1821
|
+
const result = await client.secrets.get(key);
|
|
1822
|
+
if (!result.success) {
|
|
1823
|
+
if (result.error?.code === "SECRET_NOT_FOUND") {
|
|
1824
|
+
return void 0;
|
|
1825
|
+
}
|
|
1826
|
+
console.error(`[ScaleMule Secrets] Failed to fetch ${key}:`, result.error);
|
|
1827
|
+
return void 0;
|
|
1828
|
+
}
|
|
1829
|
+
if (!noCache && result.data) {
|
|
1830
|
+
secretsCache[key] = {
|
|
1831
|
+
value: result.data.value,
|
|
1832
|
+
version: result.data.version,
|
|
1833
|
+
cachedAt: Date.now()
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
return result.data?.value;
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
console.error(`[ScaleMule Secrets] Error fetching ${key}:`, error);
|
|
1839
|
+
return void 0;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
async function requireAppSecret(key) {
|
|
1843
|
+
const value = await getAppSecret(key);
|
|
1844
|
+
if (value === void 0) {
|
|
1845
|
+
throw new Error(
|
|
1846
|
+
`Required secret '${key}' not found in ScaleMule vault. Configure it in the ScaleMule dashboard or use the SDK: scalemule.secrets.set('${key}', value)`
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
return value;
|
|
1850
|
+
}
|
|
1851
|
+
async function getAppSecretOrDefault(key, fallback) {
|
|
1852
|
+
const value = await getAppSecret(key);
|
|
1853
|
+
return value ?? fallback;
|
|
1854
|
+
}
|
|
1855
|
+
function invalidateSecretCache(key) {
|
|
1856
|
+
if (key) {
|
|
1857
|
+
delete secretsCache[key];
|
|
1858
|
+
} else {
|
|
1859
|
+
Object.keys(secretsCache).forEach((k) => delete secretsCache[k]);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
async function prefetchSecrets(keys) {
|
|
1863
|
+
await Promise.all(keys.map((key) => getAppSecret(key)));
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// src/server/bundles.ts
|
|
1867
|
+
var DEFAULT_CACHE_TTL_MS2 = 5 * 60 * 1e3;
|
|
1868
|
+
var bundlesCache = {};
|
|
1869
|
+
var globalConfig2 = {};
|
|
1870
|
+
function configureBundles(config) {
|
|
1871
|
+
globalConfig2 = { ...globalConfig2, ...config };
|
|
1872
|
+
}
|
|
1873
|
+
async function getBundle(key, resolve = true) {
|
|
1874
|
+
const cacheTtl = globalConfig2.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS2;
|
|
1875
|
+
const noCache = globalConfig2.noCache ?? false;
|
|
1876
|
+
if (!noCache) {
|
|
1877
|
+
const cached = bundlesCache[key];
|
|
1878
|
+
if (cached && Date.now() - cached.cachedAt < cacheTtl) {
|
|
1879
|
+
return cached.data;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
try {
|
|
1883
|
+
const client = createServerClient();
|
|
1884
|
+
const result = await client.bundles.get(key, resolve);
|
|
1885
|
+
if (!result.success) {
|
|
1886
|
+
if (result.error?.code === "BUNDLE_NOT_FOUND") {
|
|
1887
|
+
return void 0;
|
|
1888
|
+
}
|
|
1889
|
+
console.error(`[ScaleMule Bundles] Failed to fetch ${key}:`, result.error);
|
|
1890
|
+
return void 0;
|
|
1891
|
+
}
|
|
1892
|
+
if (!noCache && result.data) {
|
|
1893
|
+
bundlesCache[key] = {
|
|
1894
|
+
type: result.data.type,
|
|
1895
|
+
data: result.data.data,
|
|
1896
|
+
version: result.data.version,
|
|
1897
|
+
inheritsFrom: result.data.inherits_from,
|
|
1898
|
+
cachedAt: Date.now()
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
return result.data?.data;
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
console.error(`[ScaleMule Bundles] Error fetching ${key}:`, error);
|
|
1904
|
+
return void 0;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
async function requireBundle(key, resolve = true) {
|
|
1908
|
+
const value = await getBundle(key, resolve);
|
|
1909
|
+
if (value === void 0) {
|
|
1910
|
+
throw new Error(
|
|
1911
|
+
`Required bundle '${key}' not found in ScaleMule vault. Configure it in the ScaleMule dashboard`
|
|
1912
|
+
);
|
|
1913
|
+
}
|
|
1914
|
+
return value;
|
|
1915
|
+
}
|
|
1916
|
+
async function getMySqlBundle(key) {
|
|
1917
|
+
const bundle = await getBundle(key);
|
|
1918
|
+
if (!bundle) return void 0;
|
|
1919
|
+
const { host, port, username, password, database, ssl_mode } = bundle;
|
|
1920
|
+
const encodedPassword = encodeURIComponent(password);
|
|
1921
|
+
let connectionUrl = `mysql://${username}:${encodedPassword}@${host}:${port}/${database}`;
|
|
1922
|
+
if (ssl_mode) {
|
|
1923
|
+
connectionUrl += `?ssl_mode=${ssl_mode}`;
|
|
1924
|
+
}
|
|
1925
|
+
return { ...bundle, connectionUrl };
|
|
1926
|
+
}
|
|
1927
|
+
async function getPostgresBundle(key) {
|
|
1928
|
+
const bundle = await getBundle(key);
|
|
1929
|
+
if (!bundle) return void 0;
|
|
1930
|
+
const { host, port, username, password, database, ssl_mode } = bundle;
|
|
1931
|
+
const encodedPassword = encodeURIComponent(password);
|
|
1932
|
+
let connectionUrl = `postgresql://${username}:${encodedPassword}@${host}:${port}/${database}`;
|
|
1933
|
+
if (ssl_mode) {
|
|
1934
|
+
connectionUrl += `?sslmode=${ssl_mode}`;
|
|
1935
|
+
}
|
|
1936
|
+
return { ...bundle, connectionUrl };
|
|
1937
|
+
}
|
|
1938
|
+
async function getRedisBundle(key) {
|
|
1939
|
+
const bundle = await getBundle(key);
|
|
1940
|
+
if (!bundle) return void 0;
|
|
1941
|
+
const { host, port, password, database, ssl } = bundle;
|
|
1942
|
+
let connectionUrl = ssl ? "rediss://" : "redis://";
|
|
1943
|
+
if (password) {
|
|
1944
|
+
connectionUrl += `:${encodeURIComponent(password)}@`;
|
|
1945
|
+
}
|
|
1946
|
+
connectionUrl += `${host}:${port}`;
|
|
1947
|
+
if (database !== void 0) {
|
|
1948
|
+
connectionUrl += `/${database}`;
|
|
1949
|
+
}
|
|
1950
|
+
return { ...bundle, connectionUrl };
|
|
1951
|
+
}
|
|
1952
|
+
async function getS3Bundle(key) {
|
|
1953
|
+
return getBundle(key);
|
|
1954
|
+
}
|
|
1955
|
+
async function getOAuthBundle(key) {
|
|
1956
|
+
return getBundle(key);
|
|
1957
|
+
}
|
|
1958
|
+
async function getSmtpBundle(key) {
|
|
1959
|
+
return getBundle(key);
|
|
1960
|
+
}
|
|
1961
|
+
function invalidateBundleCache(key) {
|
|
1962
|
+
if (key) {
|
|
1963
|
+
delete bundlesCache[key];
|
|
1964
|
+
} else {
|
|
1965
|
+
Object.keys(bundlesCache).forEach((k) => delete bundlesCache[k]);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
async function prefetchBundles(keys) {
|
|
1969
|
+
await Promise.all(keys.map((key) => getBundle(key)));
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
export { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, OAUTH_STATE_COOKIE_NAME, SESSION_COOKIE_NAME, ScaleMuleError, ScaleMuleServer, USER_ID_COOKIE_NAME, apiHandler, buildClientContextHeaders, clearOAuthState, clearSession, configureBundles, configureSecrets, createAnalyticsRoutes, createAuthMiddleware, createAuthRoutes, createServerClient, createWebhookHandler, createWebhookRoutes, errorCodeToStatus, extractClientContext, extractClientContextFromReq, generateCSRFToken, getAppSecret, getAppSecretOrDefault, getBundle, getCSRFToken, getMySqlBundle, getOAuthBundle, getPostgresBundle, getRedisBundle, getS3Bundle, getSession, getSessionFromRequest, getSmtpBundle, invalidateBundleCache, invalidateSecretCache, parseWebhookEvent, prefetchBundles, prefetchSecrets, registerVideoWebhook, requireAppSecret, requireBundle, requireSession, setOAuthState, unwrap, validateCSRFToken, validateCSRFTokenAsync, validateOAuthState, validateOAuthStateAsync, verifyWebhookSignature, withAuth, withCSRFProtection, withCSRFToken, withSession };
|