@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -4
  3. package/dist/src/core/control-panel.d.ts.map +1 -1
  4. package/dist/src/core/control-panel.js +2 -1
  5. package/dist/src/core/control-panel.js.map +1 -1
  6. package/dist/src/core/gateway.d.ts.map +1 -1
  7. package/dist/src/core/gateway.js +25 -26
  8. package/dist/src/core/gateway.js.map +1 -1
  9. package/dist/src/plugins/auth/adapters/supertokens-adapter.d.ts.map +1 -1
  10. package/dist/src/plugins/auth/adapters/supertokens-adapter.js +50 -11
  11. package/dist/src/plugins/auth/adapters/supertokens-adapter.js.map +1 -1
  12. package/dist/src/plugins/auth/auth-plugin.d.ts.map +1 -1
  13. package/dist/src/plugins/auth/auth-plugin.js +6 -5
  14. package/dist/src/plugins/auth/auth-plugin.js.map +1 -1
  15. package/dist/src/plugins/auth/env-config.d.ts.map +1 -1
  16. package/dist/src/plugins/auth/env-config.js +4 -0
  17. package/dist/src/plugins/auth/env-config.js.map +1 -1
  18. package/dist/src/plugins/auth/types.d.ts +8 -0
  19. package/dist/src/plugins/auth/types.d.ts.map +1 -1
  20. package/dist/src/plugins/auth/types.js.map +1 -1
  21. package/dist/src/plugins/maintenance/seed-executor.d.ts.map +1 -1
  22. package/dist/src/plugins/maintenance/seed-executor.js +5 -4
  23. package/dist/src/plugins/maintenance/seed-executor.js.map +1 -1
  24. package/dist/src/plugins/maintenance-plugin.d.ts.map +1 -1
  25. package/dist/src/plugins/maintenance-plugin.js +26 -4
  26. package/dist/src/plugins/maintenance-plugin.js.map +1 -1
  27. package/dist/src/plugins/postgres-plugin.d.ts +17 -0
  28. package/dist/src/plugins/postgres-plugin.d.ts.map +1 -1
  29. package/dist/src/plugins/postgres-plugin.js +36 -11
  30. package/dist/src/plugins/postgres-plugin.js.map +1 -1
  31. package/dist/src/plugins/rate-limit/env-config.d.ts +1 -1
  32. package/dist/src/plugins/rate-limit/env-config.js +4 -4
  33. package/dist/src/plugins/rate-limit/env-config.js.map +1 -1
  34. package/dist/src/utils/index.d.ts +2 -0
  35. package/dist/src/utils/index.d.ts.map +1 -0
  36. package/dist/src/utils/index.js +2 -0
  37. package/dist/src/utils/index.js.map +1 -0
  38. package/dist/src/utils/url.d.ts +9 -0
  39. package/dist/src/utils/url.d.ts.map +1 -0
  40. package/dist/src/utils/url.js +25 -0
  41. package/dist/src/utils/url.js.map +1 -0
  42. package/dist/ui/src/dashboard/widgets/AuthStatusWidget.d.ts.map +1 -1
  43. package/dist/ui/src/dashboard/widgets/AuthStatusWidget.js +1 -1
  44. package/dist/ui/src/dashboard/widgets/AuthStatusWidget.js.map +1 -1
  45. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.d.ts.map +1 -1
  46. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js +1 -1
  47. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js.map +1 -1
  48. package/dist-ui/assets/{index-De-dCl_t.css → index-BB_TF4Cq.css} +1 -1
  49. package/dist-ui/assets/index-BdwcYEzG.js +532 -0
  50. package/dist-ui/assets/{index-Cez_jyhl.js.map → index-BdwcYEzG.js.map} +1 -1
  51. package/dist-ui/index.html +2 -2
  52. package/dist-ui-lib/index.js +69 -65
  53. package/dist-ui-lib/index.js.map +1 -1
  54. package/package.json +27 -25
  55. package/src/core/control-panel.ts +2 -1
  56. package/src/core/gateway.ts +28 -29
  57. package/src/plugins/auth/adapters/supertokens-adapter.ts +54 -13
  58. package/src/plugins/auth/auth-plugin.ts +6 -5
  59. package/src/plugins/auth/env-config.ts +4 -0
  60. package/src/plugins/auth/types.ts +11 -0
  61. package/src/plugins/maintenance/seed-executor.ts +5 -4
  62. package/src/plugins/maintenance-plugin.ts +28 -4
  63. package/src/plugins/postgres-plugin.test.ts +78 -0
  64. package/src/plugins/postgres-plugin.ts +39 -11
  65. package/src/plugins/rate-limit/env-config.ts +4 -4
  66. package/src/utils/index.ts +1 -0
  67. package/src/utils/url.test.ts +43 -0
  68. package/src/utils/url.ts +21 -0
  69. package/ui/src/dashboard/widgets/AuthStatusWidget.tsx +3 -8
  70. package/ui/src/dashboard/widgets/DatabaseOperationsWidget.tsx +17 -8
  71. 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.8.2",
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
 
@@ -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
- const apps = config.apps || [];
1366
- for (const appConfig of apps) {
1367
- // IMPORTANT: Insert QwickApps Server and Control Panel proxies BEFORE root path proxy
1368
- // This ensures /qapi/* and /cpanel requests don't get caught by frontend catch-all routing
1369
- if (appConfig.path === '/') {
1370
- // 1. Register QwickApps Server API proxy at /qapi/* BEFORE the root proxy
1371
- // These are framework APIs: SuperTokens auth, health checks, postgres, cache, etc.
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 res.status(500).json({
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
- return res.status(500).json({
167
- error: 'Auth Configuration Error',
168
- message:
169
- 'Supertokens is not properly configured. Install supertokens-node package: npm install supertokens-node',
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 /auth/* paths
77
- // This ensures Gateway forwards ALL /api/auth/* requests to control panel
78
- // where SuperTokens can handle them dynamically
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('/auth', ...primaryMiddleware);
81
+ router.use(authBasePath, ...primaryMiddleware);
81
82
  } else {
82
- router.use('/auth', primaryMiddleware);
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
- // Determine if we need tsx (for .ts/.mts files that import TS modules)
110
- // Use tsx for .mjs files too since they might import from TS source
111
- const needsTsx = scriptPath.match(/\.(mjs|ts|mts)$/);
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 = needsTsx ? [scriptPath] : [scriptPath];
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('GRANT ALL ON SCHEMA public TO qwickapps');
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('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO qwickapps');
488
- await db.queryRaw('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO qwickapps');
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('npx', ['payload', 'migrate', '--force-accept-warning'], {
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
- const match = url.match(/postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)/);
174
- if (!match) {
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
- return {
178
- user: match[1],
179
- password: match[2],
180
- host: match[3],
181
- port: parseInt(match[4], 10),
182
- database: match[5],
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') || '/rate-limit';
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 /rate-limit/config/status - Get current rate limit status
255
+ // GET /config/status - Get current rate limit status
256
256
  registry.addRoute({
257
257
  method: 'get',
258
- path: '/rate-limit/config/status',
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';