@qwickapps/server 1.8.2 → 1.10.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/CHANGELOG.md +14 -0
- package/README.md +4 -4
- package/dist/src/core/control-panel.d.ts.map +1 -1
- package/dist/src/core/control-panel.js +2 -1
- package/dist/src/core/control-panel.js.map +1 -1
- package/dist/src/core/gateway.d.ts.map +1 -1
- package/dist/src/core/gateway.js +25 -26
- package/dist/src/core/gateway.js.map +1 -1
- package/dist/src/plugins/auth/adapters/supertokens-adapter.d.ts.map +1 -1
- package/dist/src/plugins/auth/adapters/supertokens-adapter.js +50 -11
- package/dist/src/plugins/auth/adapters/supertokens-adapter.js.map +1 -1
- package/dist/src/plugins/auth/auth-plugin.d.ts.map +1 -1
- package/dist/src/plugins/auth/auth-plugin.js +6 -5
- package/dist/src/plugins/auth/auth-plugin.js.map +1 -1
- package/dist/src/plugins/auth/env-config.d.ts.map +1 -1
- package/dist/src/plugins/auth/env-config.js +4 -0
- package/dist/src/plugins/auth/env-config.js.map +1 -1
- package/dist/src/plugins/auth/types.d.ts +8 -0
- package/dist/src/plugins/auth/types.d.ts.map +1 -1
- package/dist/src/plugins/auth/types.js.map +1 -1
- package/dist/src/plugins/maintenance/seed-executor.d.ts.map +1 -1
- package/dist/src/plugins/maintenance/seed-executor.js +5 -4
- package/dist/src/plugins/maintenance/seed-executor.js.map +1 -1
- package/dist/src/plugins/maintenance-plugin.d.ts.map +1 -1
- package/dist/src/plugins/maintenance-plugin.js +26 -4
- package/dist/src/plugins/maintenance-plugin.js.map +1 -1
- package/dist/src/plugins/postgres-plugin.d.ts +17 -0
- package/dist/src/plugins/postgres-plugin.d.ts.map +1 -1
- package/dist/src/plugins/postgres-plugin.js +36 -11
- package/dist/src/plugins/postgres-plugin.js.map +1 -1
- package/dist/src/plugins/rate-limit/env-config.d.ts +1 -1
- package/dist/src/plugins/rate-limit/env-config.js +4 -4
- package/dist/src/plugins/rate-limit/env-config.js.map +1 -1
- package/dist/src/utils/index.d.ts +2 -0
- package/dist/src/utils/index.d.ts.map +1 -0
- package/dist/src/utils/index.js +2 -0
- package/dist/src/utils/index.js.map +1 -0
- package/dist/src/utils/url.d.ts +9 -0
- package/dist/src/utils/url.d.ts.map +1 -0
- package/dist/src/utils/url.js +25 -0
- package/dist/src/utils/url.js.map +1 -0
- package/dist/ui/src/dashboard/widgets/AuthStatusWidget.d.ts.map +1 -1
- package/dist/ui/src/dashboard/widgets/AuthStatusWidget.js +1 -1
- package/dist/ui/src/dashboard/widgets/AuthStatusWidget.js.map +1 -1
- package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.d.ts.map +1 -1
- package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js +1 -1
- package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js.map +1 -1
- package/dist-ui/assets/{index-De-dCl_t.css → index-BB_TF4Cq.css} +1 -1
- package/dist-ui/assets/index-BdwcYEzG.js +532 -0
- package/dist-ui/assets/{index-Cez_jyhl.js.map → index-BdwcYEzG.js.map} +1 -1
- package/dist-ui/index.html +2 -2
- package/dist-ui-lib/index.js +69 -65
- package/dist-ui-lib/index.js.map +1 -1
- package/package.json +27 -25
- package/src/core/control-panel.ts +2 -1
- package/src/core/gateway.ts +28 -29
- package/src/plugins/auth/adapters/supertokens-adapter.ts +54 -13
- package/src/plugins/auth/auth-plugin.ts +6 -5
- package/src/plugins/auth/env-config.ts +4 -0
- package/src/plugins/auth/types.ts +11 -0
- package/src/plugins/maintenance/seed-executor.ts +5 -4
- package/src/plugins/maintenance-plugin.ts +28 -4
- package/src/plugins/postgres-plugin.test.ts +78 -0
- package/src/plugins/postgres-plugin.ts +39 -11
- package/src/plugins/rate-limit/env-config.ts +4 -4
- package/src/utils/index.ts +1 -0
- package/src/utils/url.test.ts +43 -0
- package/src/utils/url.ts +21 -0
- package/ui/src/dashboard/widgets/AuthStatusWidget.tsx +3 -8
- package/ui/src/dashboard/widgets/DatabaseOperationsWidget.tsx +17 -8
- package/dist-ui/assets/index-Cez_jyhl.js +0 -532
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qwickapps/server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Plugin-based application server framework for building websites, APIs, admin dashboards, and full-stack products",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "dist/src/index.js",
|
|
6
7
|
"types": "dist/src/index.d.ts",
|
|
7
8
|
"exports": {
|
|
@@ -29,6 +30,29 @@
|
|
|
29
30
|
"ui",
|
|
30
31
|
"CHANGELOG.md"
|
|
31
32
|
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "npm run build:ui-lib && npm run build:server && npm run build:ui",
|
|
35
|
+
"build:server": "tsc",
|
|
36
|
+
"build:ui": "cd ui && vite build",
|
|
37
|
+
"build:ui-lib": "cd ui && vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json",
|
|
38
|
+
"build:clean": "rm -rf dist dist-ui dist-ui-lib && npm run build",
|
|
39
|
+
"dev": "tsc --watch",
|
|
40
|
+
"dev:ui": "cd ui && vite",
|
|
41
|
+
"clean": "rm -rf dist dist-ui dist-ui-lib node_modules",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"test:coverage": "vitest run --coverage",
|
|
45
|
+
"test:e2e": "playwright test",
|
|
46
|
+
"test:e2e:ui": "playwright test --ui",
|
|
47
|
+
"test:e2e:headed": "playwright test --headed",
|
|
48
|
+
"demo": "npm run build:server && pnpm tsx examples/demo-server.ts",
|
|
49
|
+
"demo:gateway": "npm run build:server && pnpm tsx examples/demo-gateway.ts",
|
|
50
|
+
"demo:all": "npm run build:server && pnpm tsx examples/demo-all.ts",
|
|
51
|
+
"type-check": "tsc --noEmit",
|
|
52
|
+
"type-check:ui": "cd ui && tsc --noEmit",
|
|
53
|
+
"validate:clean-install": "./qa/clean-install/validate.sh",
|
|
54
|
+
"prepublishOnly": "npm run test && npm run build && npm run validate:clean-install"
|
|
55
|
+
},
|
|
32
56
|
"dependencies": {
|
|
33
57
|
"@qwickapps/logging": "^1.0.2",
|
|
34
58
|
"compression": "^1.7.4",
|
|
@@ -115,32 +139,10 @@
|
|
|
115
139
|
},
|
|
116
140
|
"repository": {
|
|
117
141
|
"type": "git",
|
|
118
|
-
"url": "https://github.com/qwickapps/server.git"
|
|
142
|
+
"url": "git+https://github.com/qwickapps/server.git"
|
|
119
143
|
},
|
|
120
144
|
"homepage": "https://github.com/qwickapps/server#readme",
|
|
121
145
|
"publishConfig": {
|
|
122
146
|
"access": "public"
|
|
123
|
-
},
|
|
124
|
-
"scripts": {
|
|
125
|
-
"build": "npm run build:ui-lib && npm run build:server && npm run build:ui",
|
|
126
|
-
"build:server": "tsc",
|
|
127
|
-
"build:ui": "cd ui && vite build",
|
|
128
|
-
"build:ui-lib": "cd ui && vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json",
|
|
129
|
-
"build:clean": "rm -rf dist dist-ui dist-ui-lib && npm run build",
|
|
130
|
-
"dev": "tsc --watch",
|
|
131
|
-
"dev:ui": "cd ui && vite",
|
|
132
|
-
"clean": "rm -rf dist dist-ui dist-ui-lib node_modules",
|
|
133
|
-
"test": "vitest run",
|
|
134
|
-
"test:watch": "vitest",
|
|
135
|
-
"test:coverage": "vitest run --coverage",
|
|
136
|
-
"test:e2e": "playwright test",
|
|
137
|
-
"test:e2e:ui": "playwright test --ui",
|
|
138
|
-
"test:e2e:headed": "playwright test --headed",
|
|
139
|
-
"demo": "npm run build:server && pnpm tsx examples/demo-server.ts",
|
|
140
|
-
"demo:gateway": "npm run build:server && pnpm tsx examples/demo-gateway.ts",
|
|
141
|
-
"demo:all": "npm run build:server && pnpm tsx examples/demo-all.ts",
|
|
142
|
-
"type-check": "tsc --noEmit",
|
|
143
|
-
"type-check:ui": "cd ui && tsc --noEmit",
|
|
144
|
-
"validate:clean-install": "./qa/clean-install/validate.sh"
|
|
145
147
|
}
|
|
146
|
-
}
|
|
148
|
+
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './plugin-registry.js';
|
|
38
38
|
import { bearerTokenAuth } from '../plugins/api-keys/index.js';
|
|
39
39
|
import { createCorePlugin } from '../plugins/core/index.js';
|
|
40
|
+
import { sanitizeUrl } from '../utils/url.js';
|
|
40
41
|
|
|
41
42
|
// Get the package root directory for serving UI assets
|
|
42
43
|
const _filename = fileURLToPath(import.meta.url);
|
|
@@ -506,7 +507,7 @@ function generateLandingPageHtml(options: {
|
|
|
506
507
|
const linksHtml = links
|
|
507
508
|
.map(
|
|
508
509
|
(link) =>
|
|
509
|
-
`<a href="${link.url}" class="link">${link.label}</a>`
|
|
510
|
+
`<a href="${sanitizeUrl(link.url)}" class="link">${link.label}</a>`
|
|
510
511
|
)
|
|
511
512
|
.join('');
|
|
512
513
|
|
package/src/core/gateway.ts
CHANGED
|
@@ -34,6 +34,7 @@ import express from 'express';
|
|
|
34
34
|
import { existsSync, readFileSync } from 'fs';
|
|
35
35
|
import { resolve, join, dirname } from 'path';
|
|
36
36
|
import { fileURLToPath } from 'url';
|
|
37
|
+
import { sanitizeUrl } from '../utils/url.js';
|
|
37
38
|
|
|
38
39
|
// Get QwickApps Server version from package.json
|
|
39
40
|
const _filename = fileURLToPath(import.meta.url);
|
|
@@ -287,7 +288,7 @@ function generateLandingPageHtml(
|
|
|
287
288
|
const linksHtml = links
|
|
288
289
|
.map(
|
|
289
290
|
(link) =>
|
|
290
|
-
`<a href="${link.url}" class="link">${link.label}</a>`
|
|
291
|
+
`<a href="${sanitizeUrl(link.url)}" class="link">${link.label}</a>`
|
|
291
292
|
)
|
|
292
293
|
.join('');
|
|
293
294
|
|
|
@@ -846,7 +847,7 @@ function generateMaintenancePageHtml(
|
|
|
846
847
|
}
|
|
847
848
|
|
|
848
849
|
const contactHtml = config.contactUrl
|
|
849
|
-
? `<a href="${config.contactUrl}" class="btn btn-secondary">
|
|
850
|
+
? `<a href="${sanitizeUrl(config.contactUrl)}" class="btn btn-secondary">
|
|
850
851
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
|
851
852
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
|
852
853
|
<polyline points="22,6 12,13 2,6"/>
|
|
@@ -1362,35 +1363,33 @@ export function createGateway(config: GatewayConfig): GatewayInstance {
|
|
|
1362
1363
|
},
|
|
1363
1364
|
});
|
|
1364
1365
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
// Payload CMS uses natural Next.js /api/* path
|
|
1373
|
-
app.use((req, res, next) => {
|
|
1374
|
-
if (req.path.startsWith('/qapi/')) {
|
|
1375
|
-
return qwickAppsApiProxy(req, res, next);
|
|
1376
|
-
}
|
|
1377
|
-
next();
|
|
1378
|
-
});
|
|
1379
|
-
logger.debug(`Setting up proxy: /qapi/* -> http://localhost:${cpPort} (QwickApps Server APIs)`);
|
|
1380
|
-
mountedApps.push({ path: '/qapi', type: 'proxy', target: `http://localhost:${cpPort}` });
|
|
1381
|
-
|
|
1382
|
-
// 2. Register Control Panel UI proxy BEFORE the root proxy
|
|
1383
|
-
app.use(cpPath, cpUiProxy);
|
|
1384
|
-
mountedApps.push({ path: cpPath, type: 'proxy', target: `http://localhost:${cpPort}` });
|
|
1385
|
-
|
|
1386
|
-
// 3. Setup WebSocket upgrade handling for control panel UI
|
|
1387
|
-
server!.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
1388
|
-
if (req.url?.startsWith(cpPath)) {
|
|
1389
|
-
cpUiProxy.upgrade?.(req, socket as Socket, head);
|
|
1390
|
-
}
|
|
1391
|
-
});
|
|
1366
|
+
// Always register QwickApps Server API proxy (/qapi/*) and Control Panel proxy (/cpanel)
|
|
1367
|
+
// regardless of whether any apps are mounted. This ensures createControlPanel() callers
|
|
1368
|
+
// (which pass no apps) still get /cpanel and /api routes proxied from the gateway port.
|
|
1369
|
+
// 1. Register QwickApps Server API proxy at /qapi/*
|
|
1370
|
+
app.use((req, res, next) => {
|
|
1371
|
+
if (req.path.startsWith('/qapi/')) {
|
|
1372
|
+
return qwickAppsApiProxy(req, res, next);
|
|
1392
1373
|
}
|
|
1374
|
+
next();
|
|
1375
|
+
});
|
|
1376
|
+
logger.debug(`Setting up proxy: /qapi/* -> http://localhost:${cpPort} (QwickApps Server APIs)`);
|
|
1377
|
+
mountedApps.push({ path: '/qapi', type: 'proxy', target: `http://localhost:${cpPort}` });
|
|
1378
|
+
|
|
1379
|
+
// 2. Register Control Panel UI proxy
|
|
1380
|
+
app.use(cpPath, cpUiProxy);
|
|
1381
|
+
mountedApps.push({ path: cpPath, type: 'proxy', target: `http://localhost:${cpPort}` });
|
|
1393
1382
|
|
|
1383
|
+
// 3. Setup WebSocket upgrade handling for control panel UI
|
|
1384
|
+
server!.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
1385
|
+
if (req.url?.startsWith(cpPath)) {
|
|
1386
|
+
cpUiProxy.upgrade?.(req, socket as Socket, head);
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
const apps = config.apps || [];
|
|
1391
|
+
for (const appConfig of apps) {
|
|
1392
|
+
// For apps with a root path, skip re-registering qapi/cpanel proxies (already done above)
|
|
1394
1393
|
// Now register the app itself
|
|
1395
1394
|
if (appConfig.source.type === 'proxy') {
|
|
1396
1395
|
setupProxyApp(appConfig, server);
|
|
@@ -47,14 +47,12 @@ export function supertokensAdapter(config: SupertokensAdapterConfig): AuthAdapte
|
|
|
47
47
|
// Store response on request for later use in getUser()
|
|
48
48
|
(req as SupertokensExtendedRequest)[REQUEST_RES_KEY] = res;
|
|
49
49
|
|
|
50
|
-
// Skip if already initialized with error
|
|
50
|
+
// Skip if already initialized with error — let auth-checking middleware
|
|
51
|
+
// decide whether to block the request based on authRequired config.
|
|
52
|
+
// Returning 500 here blocks ALL routes (including /auth/config/status)
|
|
53
|
+
// even when authRequired is false.
|
|
51
54
|
if (initializationError) {
|
|
52
|
-
return
|
|
53
|
-
error: 'Auth Configuration Error',
|
|
54
|
-
message:
|
|
55
|
-
'Supertokens is not properly configured. Install supertokens-node package: npm install supertokens-node',
|
|
56
|
-
details: initializationError.message,
|
|
57
|
-
});
|
|
55
|
+
return next();
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
// Lazy initialize Supertokens
|
|
@@ -74,6 +72,51 @@ export function supertokensAdapter(config: SupertokensAdapterConfig): AuthAdapte
|
|
|
74
72
|
recipeList.push(EmailPassword.default.init());
|
|
75
73
|
}
|
|
76
74
|
|
|
75
|
+
// Add Passwordless (magic link) recipe if enabled
|
|
76
|
+
if (config.enablePasswordless) {
|
|
77
|
+
const Passwordless = await import('supertokens-node/recipe/passwordless');
|
|
78
|
+
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
const passwordlessConfig: any = {
|
|
81
|
+
contactMethod: 'EMAIL',
|
|
82
|
+
flowType: 'MAGIC_LINK',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Override email delivery with Resend if API key is provided
|
|
86
|
+
if (config.resendApiKey) {
|
|
87
|
+
const resendApiKey = config.resendApiKey;
|
|
88
|
+
const fromEmail = config.resendFromEmail || 'noreply@faabzi.com';
|
|
89
|
+
const appName = config.appName;
|
|
90
|
+
|
|
91
|
+
passwordlessConfig.emailDelivery = {
|
|
92
|
+
service: {
|
|
93
|
+
sendEmail: async ({ email, urlWithLinkCode }: { email: string; urlWithLinkCode?: string }) => {
|
|
94
|
+
try {
|
|
95
|
+
await fetch('https://api.resend.com/emails', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${resendApiKey}`,
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
from: fromEmail,
|
|
103
|
+
to: email,
|
|
104
|
+
subject: `Sign in to ${appName}`,
|
|
105
|
+
html: `<p>Click the link below to sign in to ${appName}.</p><p>This link expires in 15 minutes.</p><p><a href="${urlWithLinkCode}" style="display:inline-block;padding:12px 24px;background-color:#1a1a1a;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Sign in</a></p><p>Or copy this URL: ${urlWithLinkCode}</p><p>If you did not request this, you can safely ignore this email.</p>`,
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error('[SupertokensAdapter] Failed to send magic link email via Resend:', err);
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
recipeList.push(Passwordless.default.init(passwordlessConfig));
|
|
118
|
+
}
|
|
119
|
+
|
|
77
120
|
// Add ThirdParty recipe if any social providers configured
|
|
78
121
|
if (config.socialProviders) {
|
|
79
122
|
// Build provider configurations using Supertokens ProviderInput type
|
|
@@ -163,12 +206,10 @@ export function supertokensAdapter(config: SupertokensAdapterConfig): AuthAdapte
|
|
|
163
206
|
initializationError =
|
|
164
207
|
error instanceof Error ? error : new Error('Failed to initialize Supertokens');
|
|
165
208
|
console.error('[SupertokensAdapter] Initialization error:', error);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
details: initializationError.message,
|
|
171
|
-
});
|
|
209
|
+
// Let the auth-checking middleware decide whether to block this request.
|
|
210
|
+
// Non-auth routes (e.g. /auth/config/status) must remain accessible
|
|
211
|
+
// so the UI can display the configuration error state.
|
|
212
|
+
return next();
|
|
172
213
|
}
|
|
173
214
|
}
|
|
174
215
|
|
|
@@ -73,13 +73,14 @@ export function createAuthPlugin(config: AuthPluginConfig): Plugin {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// Register SuperTokens middleware on router for
|
|
77
|
-
//
|
|
78
|
-
//
|
|
76
|
+
// Register SuperTokens middleware on router for auth paths
|
|
77
|
+
// Uses config.apiBasePath so Gateway forwards requests to the correct path
|
|
78
|
+
// e.g. SUPERTOKENS_API_BASE_PATH=/qapi/auth → router.use('/qapi/auth', ...)
|
|
79
|
+
const authBasePath = config.apiBasePath ?? '/auth';
|
|
79
80
|
if (Array.isArray(primaryMiddleware)) {
|
|
80
|
-
router.use(
|
|
81
|
+
router.use(authBasePath, ...primaryMiddleware);
|
|
81
82
|
} else {
|
|
82
|
-
router.use(
|
|
83
|
+
router.use(authBasePath, primaryMiddleware);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// Add the auth checking middleware to router (not app)
|
|
@@ -149,6 +149,9 @@ function parseSupertokensEnv(): EnvParseResult<SupertokensAdapterConfig> {
|
|
|
149
149
|
apiBasePath: getEnv('SUPERTOKENS_API_BASE_PATH') ?? '/auth',
|
|
150
150
|
websiteBasePath: getEnv('SUPERTOKENS_WEBSITE_BASE_PATH') ?? '/auth',
|
|
151
151
|
enableEmailPassword: getEnvBool('SUPERTOKENS_ENABLE_EMAIL_PASSWORD', true),
|
|
152
|
+
enablePasswordless: getEnvBool('SUPERTOKENS_ENABLE_PASSWORDLESS', false),
|
|
153
|
+
resendApiKey: getEnv('RESEND_API_KEY'),
|
|
154
|
+
resendFromEmail: getEnv('RESEND_FROM_EMAIL'),
|
|
152
155
|
};
|
|
153
156
|
|
|
154
157
|
// Parse social providers
|
|
@@ -402,6 +405,7 @@ export function createAuthPluginFromEnv(options?: AuthEnvPluginOptions): Plugin
|
|
|
402
405
|
excludePaths,
|
|
403
406
|
authRequired,
|
|
404
407
|
debug,
|
|
408
|
+
apiBasePath: getEnv('SUPERTOKENS_API_BASE_PATH') ?? getEnv('AUTH_API_BASE_PATH'),
|
|
405
409
|
onUnauthorized: options?.onUnauthorized,
|
|
406
410
|
onAuthenticated: options?.onAuthenticated,
|
|
407
411
|
};
|
|
@@ -155,6 +155,15 @@ export interface SupertokensAdapterConfig {
|
|
|
155
155
|
/** Enable email/password auth (default: true) */
|
|
156
156
|
enableEmailPassword?: boolean;
|
|
157
157
|
|
|
158
|
+
/** Enable passwordless (magic link) auth (default: false) */
|
|
159
|
+
enablePasswordless?: boolean;
|
|
160
|
+
|
|
161
|
+
/** Resend API key for magic link email delivery (optional — uses SuperTokens core delivery if not set) */
|
|
162
|
+
resendApiKey?: string;
|
|
163
|
+
|
|
164
|
+
/** From email address for magic link emails */
|
|
165
|
+
resendFromEmail?: string;
|
|
166
|
+
|
|
158
167
|
/** Social login providers */
|
|
159
168
|
socialProviders?: {
|
|
160
169
|
google?: { clientId: string; clientSecret: string };
|
|
@@ -180,6 +189,8 @@ export interface AuthPluginConfig {
|
|
|
180
189
|
excludePaths?: string[];
|
|
181
190
|
/** Whether auth is required for all routes (default: true) */
|
|
182
191
|
authRequired?: boolean;
|
|
192
|
+
/** API base path for auth routes registered on the Express router (default: '/auth') */
|
|
193
|
+
apiBasePath?: string;
|
|
183
194
|
/** Custom unauthorized handler */
|
|
184
195
|
onUnauthorized?: (req: Request, res: Response) => void;
|
|
185
196
|
/**
|
|
@@ -106,11 +106,12 @@ export class SeedExecutor {
|
|
|
106
106
|
this.outputSize = 0;
|
|
107
107
|
|
|
108
108
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
109
|
+
// .ts/.mts files require tsx for TypeScript compilation.
|
|
110
|
+
// .mjs files are native ESM and run directly with node — tsx is not
|
|
111
|
+
// available in the system PATH in Docker production builds.
|
|
112
|
+
const needsTsx = scriptPath.match(/\.(ts|mts)$/);
|
|
112
113
|
const execCommand = needsTsx ? 'tsx' : process.execPath;
|
|
113
|
-
const execArgs =
|
|
114
|
+
const execArgs = [scriptPath];
|
|
114
115
|
|
|
115
116
|
// Spawn process with TypeScript support if needed
|
|
116
117
|
this.child = spawn(execCommand, execArgs, {
|
|
@@ -416,6 +416,8 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
|
|
|
416
416
|
} catch (error) {
|
|
417
417
|
logger.error('Seed execution failed', { name, error });
|
|
418
418
|
|
|
419
|
+
const duration = Date.now() - startTime;
|
|
420
|
+
|
|
419
421
|
// Send error event via SSE to notify client
|
|
420
422
|
res.write(`data: ${JSON.stringify({
|
|
421
423
|
type: 'error',
|
|
@@ -423,6 +425,13 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
|
|
|
423
425
|
timestamp: new Date().toISOString()
|
|
424
426
|
})}\n\n`);
|
|
425
427
|
|
|
428
|
+
// Send exit event so the UI can transition out of the "Running..." state
|
|
429
|
+
res.write(`data: ${JSON.stringify({
|
|
430
|
+
type: 'exit',
|
|
431
|
+
data: JSON.stringify({ exitCode: 1, duration }),
|
|
432
|
+
timestamp: new Date().toISOString()
|
|
433
|
+
})}\n\n`);
|
|
434
|
+
|
|
426
435
|
// Update execution record as failed
|
|
427
436
|
if (hasPostgres() && executionId) {
|
|
428
437
|
const db = getPostgres();
|
|
@@ -476,16 +485,31 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
|
|
|
476
485
|
|
|
477
486
|
const db = getPostgres();
|
|
478
487
|
|
|
488
|
+
// Derive the DB role from the connection URL so GRANT statements
|
|
489
|
+
// work regardless of which user owns the schema.
|
|
490
|
+
let dbRole = 'qwickapps';
|
|
491
|
+
if (config.databaseUrl) {
|
|
492
|
+
try {
|
|
493
|
+
const parsedUrl = new URL(config.databaseUrl);
|
|
494
|
+
const urlUser = parsedUrl.username;
|
|
495
|
+
if (urlUser && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(urlUser)) {
|
|
496
|
+
dbRole = urlUser;
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
// Keep default if URL is unparseable
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
479
503
|
// Drop and recreate public schema (removes all tables, data, etc.)
|
|
480
504
|
await db.queryRaw('DROP SCHEMA IF EXISTS public CASCADE');
|
|
481
505
|
await db.queryRaw('CREATE SCHEMA public');
|
|
482
506
|
await db.queryRaw('GRANT ALL ON SCHEMA public TO public');
|
|
483
507
|
await db.queryRaw('GRANT ALL ON SCHEMA public TO postgres');
|
|
484
|
-
await db.queryRaw(
|
|
508
|
+
await db.queryRaw(`GRANT ALL ON SCHEMA public TO ${dbRole}`);
|
|
485
509
|
|
|
486
510
|
// Grant default privileges for future tables and sequences
|
|
487
|
-
await db.queryRaw(
|
|
488
|
-
await db.queryRaw(
|
|
511
|
+
await db.queryRaw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${dbRole}`);
|
|
512
|
+
await db.queryRaw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${dbRole}`);
|
|
489
513
|
|
|
490
514
|
res.json({
|
|
491
515
|
success: true,
|
|
@@ -795,7 +819,7 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
|
|
|
795
819
|
// Execute Payload migration command
|
|
796
820
|
const { spawn } = await import('child_process');
|
|
797
821
|
|
|
798
|
-
migrationProcess = spawn('
|
|
822
|
+
migrationProcess = spawn('pnpm', ['exec', 'payload', 'migrate', '--force-accept-warning'], {
|
|
799
823
|
cwd: process.cwd(),
|
|
800
824
|
env: {
|
|
801
825
|
...process.env,
|
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
createPostgresPlugin,
|
|
37
37
|
getPostgres,
|
|
38
38
|
hasPostgres,
|
|
39
|
+
parseConnectionUrl,
|
|
40
|
+
isManagedDatabase,
|
|
39
41
|
type PostgresPluginConfig,
|
|
40
42
|
} from './postgres-plugin.js';
|
|
41
43
|
import type { PluginRegistry } from '../core/plugin-registry.js';
|
|
@@ -221,6 +223,82 @@ describe('PostgreSQL Plugin', () => {
|
|
|
221
223
|
});
|
|
222
224
|
});
|
|
223
225
|
|
|
226
|
+
describe('parseConnectionUrl', () => {
|
|
227
|
+
it('should parse a standard URL with explicit port', () => {
|
|
228
|
+
const result = parseConnectionUrl('postgresql://myuser:mypass@localhost:5432/mydb');
|
|
229
|
+
expect(result).toEqual({ user: 'myuser', password: 'mypass', host: 'localhost', port: 5432, database: 'mydb' });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should default port to 5432 when omitted (Neon URL)', () => {
|
|
233
|
+
const result = parseConnectionUrl('postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb?sslmode=require');
|
|
234
|
+
expect(result.host).toBe('ep-xxx.us-east-2.aws.neon.tech');
|
|
235
|
+
expect(result.port).toBe(5432);
|
|
236
|
+
expect(result.database).toBe('neondb');
|
|
237
|
+
expect(result.user).toBe('user');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should parse a Supabase URL without port', () => {
|
|
241
|
+
const result = parseConnectionUrl('postgresql://user:pass@db.abc123.supabase.co/postgres');
|
|
242
|
+
expect(result.host).toBe('db.abc123.supabase.co');
|
|
243
|
+
expect(result.port).toBe(5432);
|
|
244
|
+
expect(result.database).toBe('postgres');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should normalise postgres:// scheme to postgresql://', () => {
|
|
248
|
+
const result = parseConnectionUrl('postgres://user:pass@localhost:5432/testdb');
|
|
249
|
+
expect(result.host).toBe('localhost');
|
|
250
|
+
expect(result.database).toBe('testdb');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should strip query-string params from the database name', () => {
|
|
254
|
+
const result = parseConnectionUrl('postgresql://user:pass@host.neon.tech/mydb?sslmode=require&connect_timeout=10');
|
|
255
|
+
expect(result.database).toBe('mydb');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should decode percent-encoded characters in password', () => {
|
|
259
|
+
const result = parseConnectionUrl('postgresql://user:pass%40word@ep-xxx.neon.tech/mydb?sslmode=require');
|
|
260
|
+
expect(result.user).toBe('user');
|
|
261
|
+
expect(result.password).toBe('pass@word');
|
|
262
|
+
expect(result.host).toBe('ep-xxx.neon.tech');
|
|
263
|
+
expect(result.database).toBe('mydb');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should handle empty password without throwing', () => {
|
|
267
|
+
const result = parseConnectionUrl('postgresql://user:@localhost:5432/mydb');
|
|
268
|
+
expect(result.user).toBe('user');
|
|
269
|
+
expect(result.password).toBe('');
|
|
270
|
+
expect(result.host).toBe('localhost');
|
|
271
|
+
expect(result.port).toBe(5432);
|
|
272
|
+
expect(result.database).toBe('mydb');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should throw on an invalid URL', () => {
|
|
276
|
+
expect(() => parseConnectionUrl('not-a-url')).toThrow('Invalid PostgreSQL connection URL format');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('isManagedDatabase', () => {
|
|
281
|
+
it('should return true for *.neon.tech hosts', () => {
|
|
282
|
+
expect(isManagedDatabase('ep-xxx.us-east-2.aws.neon.tech')).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should return true for *.supabase.co hosts', () => {
|
|
286
|
+
expect(isManagedDatabase('db.abc123.supabase.co')).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should return false for localhost', () => {
|
|
290
|
+
expect(isManagedDatabase('localhost')).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should return false for RDS hosts', () => {
|
|
294
|
+
expect(isManagedDatabase('mydb.cluster-xyz.us-east-1.rds.amazonaws.com')).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should return false for a domain that merely contains but does not end with neon.tech', () => {
|
|
298
|
+
expect(isManagedDatabase('neon.tech.example.com')).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
224
302
|
describe('onStop', () => {
|
|
225
303
|
it('should close pool and unregister instance', async () => {
|
|
226
304
|
const plugin = createPostgresPlugin(mockConfig, 'test');
|
|
@@ -161,26 +161,43 @@ export interface PostgresInstance {
|
|
|
161
161
|
const instances = new Map<string, PostgresInstance>();
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Parse database connection URL to extract components
|
|
164
|
+
* Parse database connection URL to extract components.
|
|
165
|
+
* Supports both `postgresql://` and `postgres://` schemes,
|
|
166
|
+
* optional port (defaults to 5432), and query-string parameters.
|
|
165
167
|
*/
|
|
166
|
-
function parseConnectionUrl(url: string): {
|
|
168
|
+
export function parseConnectionUrl(url: string): {
|
|
167
169
|
user: string;
|
|
168
170
|
password: string;
|
|
169
171
|
host: string;
|
|
170
172
|
port: number;
|
|
171
173
|
database: string;
|
|
172
174
|
} {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
// Normalise postgres:// → postgresql:// so the URL constructor accepts it
|
|
176
|
+
const normalised = url.replace(/^postgres:\/\//, 'postgresql://');
|
|
177
|
+
let parsed: URL;
|
|
178
|
+
try {
|
|
179
|
+
parsed = new URL(normalised);
|
|
180
|
+
} catch {
|
|
175
181
|
throw new Error('Invalid PostgreSQL connection URL format');
|
|
176
182
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
const user = decodeURIComponent(parsed.username);
|
|
184
|
+
const password = decodeURIComponent(parsed.password);
|
|
185
|
+
const host = parsed.hostname;
|
|
186
|
+
const port = parsed.port ? parseInt(parsed.port, 10) : 5432;
|
|
187
|
+
// pathname starts with '/' — strip it to get the database name
|
|
188
|
+
const database = decodeURIComponent(parsed.pathname.slice(1));
|
|
189
|
+
if (!user || !host || !database) {
|
|
190
|
+
throw new Error('Invalid PostgreSQL connection URL format');
|
|
191
|
+
}
|
|
192
|
+
return { user, password, host, port, database };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Returns true when the host belongs to a known managed-database provider
|
|
197
|
+
* (Neon, Supabase) where destructive operations like DROP DATABASE are unsafe.
|
|
198
|
+
*/
|
|
199
|
+
export function isManagedDatabase(host: string): boolean {
|
|
200
|
+
return host.endsWith('.neon.tech') || host.endsWith('.supabase.co');
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
/**
|
|
@@ -567,6 +584,8 @@ export function createPostgresPlugin(
|
|
|
567
584
|
}
|
|
568
585
|
}
|
|
569
586
|
|
|
587
|
+
const managed = connParams ? isManagedDatabase(connParams.host) : false;
|
|
588
|
+
|
|
570
589
|
try {
|
|
571
590
|
await targetInstance.query('SELECT 1');
|
|
572
591
|
res.json({
|
|
@@ -576,6 +595,7 @@ export function createPostgresPlugin(
|
|
|
576
595
|
user: connParams?.user,
|
|
577
596
|
host: connParams?.host,
|
|
578
597
|
port: connParams?.port,
|
|
598
|
+
managed,
|
|
579
599
|
autoInitializeEnabled: config.autoInitialize !== false,
|
|
580
600
|
adminCredentialsProvided: !!(config.adminUser && config.adminPassword),
|
|
581
601
|
});
|
|
@@ -587,6 +607,7 @@ export function createPostgresPlugin(
|
|
|
587
607
|
user: connParams?.user,
|
|
588
608
|
host: connParams?.host,
|
|
589
609
|
port: connParams?.port,
|
|
610
|
+
managed,
|
|
590
611
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
591
612
|
autoInitializeEnabled: config.autoInitialize !== false,
|
|
592
613
|
adminCredentialsProvided: !!(config.adminUser && config.adminPassword),
|
|
@@ -676,6 +697,13 @@ export function createPostgresPlugin(
|
|
|
676
697
|
}
|
|
677
698
|
|
|
678
699
|
const connParams = parseConnectionUrl(config.url);
|
|
700
|
+
|
|
701
|
+
if (isManagedDatabase(connParams.host)) {
|
|
702
|
+
return res.status(403).json({
|
|
703
|
+
message: 'Delete and recreate is not supported for managed databases (Neon, Supabase). Manage your database through the provider dashboard.',
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
679
707
|
const effectiveAdminUser = adminUser || config.adminUser;
|
|
680
708
|
const effectiveAdminPassword = adminPassword || config.adminPassword;
|
|
681
709
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - RATE_LIMIT_CLEANUP_ENABLED: Enable cleanup job (default: true)
|
|
13
13
|
* - RATE_LIMIT_CLEANUP_INTERVAL_MS: Cleanup interval in ms (default: 300000 = 5 minutes)
|
|
14
14
|
* - RATE_LIMIT_API_ENABLED: Enable status API endpoints (default: true)
|
|
15
|
-
* - RATE_LIMIT_API_PREFIX: API route prefix (default: /rate-limit)
|
|
15
|
+
* - RATE_LIMIT_API_PREFIX: API route prefix (default: empty, framework adds /rate-limit automatically)
|
|
16
16
|
* - RATE_LIMIT_DEBUG: Enable debug logging (default: false)
|
|
17
17
|
*
|
|
18
18
|
* PostgreSQL Store (via postgres-plugin):
|
|
@@ -181,7 +181,7 @@ export function createRateLimitPluginFromEnv(options?: RateLimitEnvPluginOptions
|
|
|
181
181
|
const cleanupEnabled = getEnvBool('RATE_LIMIT_CLEANUP_ENABLED', true);
|
|
182
182
|
const cleanupIntervalMs = getEnvInt('RATE_LIMIT_CLEANUP_INTERVAL_MS', 300000);
|
|
183
183
|
const apiEnabled = getEnvBool('RATE_LIMIT_API_ENABLED', true);
|
|
184
|
-
const apiPrefix = getEnv('RATE_LIMIT_API_PREFIX') || '
|
|
184
|
+
const apiPrefix = getEnv('RATE_LIMIT_API_PREFIX') || '';
|
|
185
185
|
const debug = options?.debug ?? getEnvBool('RATE_LIMIT_DEBUG', false);
|
|
186
186
|
|
|
187
187
|
// PostgreSQL store config
|
|
@@ -252,10 +252,10 @@ export function getRateLimitConfigStatus(): RateLimitConfigStatus {
|
|
|
252
252
|
* Register config API routes
|
|
253
253
|
*/
|
|
254
254
|
function registerConfigRoutes(registry: PluginRegistry): void {
|
|
255
|
-
// GET /
|
|
255
|
+
// GET /config/status - Get current rate limit status
|
|
256
256
|
registry.addRoute({
|
|
257
257
|
method: 'get',
|
|
258
|
-
path: '/
|
|
258
|
+
path: '/config/status',
|
|
259
259
|
pluginId: 'rate-limit',
|
|
260
260
|
handler: (_req: Request, res: Response) => {
|
|
261
261
|
res.json(getRateLimitConfigStatus());
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { sanitizeUrl } from './url.js';
|