@qwickapps/server 1.9.0 → 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 (66) hide show
  1. package/dist/src/core/control-panel.d.ts.map +1 -1
  2. package/dist/src/core/control-panel.js +2 -1
  3. package/dist/src/core/control-panel.js.map +1 -1
  4. package/dist/src/core/gateway.d.ts.map +1 -1
  5. package/dist/src/core/gateway.js +25 -26
  6. package/dist/src/core/gateway.js.map +1 -1
  7. package/dist/src/plugins/auth/adapters/supertokens-adapter.d.ts.map +1 -1
  8. package/dist/src/plugins/auth/adapters/supertokens-adapter.js +50 -11
  9. package/dist/src/plugins/auth/adapters/supertokens-adapter.js.map +1 -1
  10. package/dist/src/plugins/auth/auth-plugin.d.ts.map +1 -1
  11. package/dist/src/plugins/auth/auth-plugin.js +6 -5
  12. package/dist/src/plugins/auth/auth-plugin.js.map +1 -1
  13. package/dist/src/plugins/auth/env-config.d.ts.map +1 -1
  14. package/dist/src/plugins/auth/env-config.js +4 -0
  15. package/dist/src/plugins/auth/env-config.js.map +1 -1
  16. package/dist/src/plugins/auth/types.d.ts +8 -0
  17. package/dist/src/plugins/auth/types.d.ts.map +1 -1
  18. package/dist/src/plugins/auth/types.js.map +1 -1
  19. package/dist/src/plugins/maintenance/seed-executor.d.ts.map +1 -1
  20. package/dist/src/plugins/maintenance/seed-executor.js +5 -4
  21. package/dist/src/plugins/maintenance/seed-executor.js.map +1 -1
  22. package/dist/src/plugins/maintenance-plugin.d.ts.map +1 -1
  23. package/dist/src/plugins/maintenance-plugin.js +26 -4
  24. package/dist/src/plugins/maintenance-plugin.js.map +1 -1
  25. package/dist/src/plugins/postgres-plugin.d.ts +17 -0
  26. package/dist/src/plugins/postgres-plugin.d.ts.map +1 -1
  27. package/dist/src/plugins/postgres-plugin.js +36 -11
  28. package/dist/src/plugins/postgres-plugin.js.map +1 -1
  29. package/dist/src/plugins/rate-limit/env-config.d.ts +1 -1
  30. package/dist/src/plugins/rate-limit/env-config.js +4 -4
  31. package/dist/src/plugins/rate-limit/env-config.js.map +1 -1
  32. package/dist/src/utils/index.d.ts +2 -0
  33. package/dist/src/utils/index.d.ts.map +1 -0
  34. package/dist/src/utils/index.js +2 -0
  35. package/dist/src/utils/index.js.map +1 -0
  36. package/dist/src/utils/url.d.ts +9 -0
  37. package/dist/src/utils/url.d.ts.map +1 -0
  38. package/dist/src/utils/url.js +25 -0
  39. package/dist/src/utils/url.js.map +1 -0
  40. package/dist/ui/src/dashboard/widgets/AuthStatusWidget.d.ts.map +1 -1
  41. package/dist/ui/src/dashboard/widgets/AuthStatusWidget.js +1 -1
  42. package/dist/ui/src/dashboard/widgets/AuthStatusWidget.js.map +1 -1
  43. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.d.ts.map +1 -1
  44. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js +1 -1
  45. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js.map +1 -1
  46. package/dist-ui/assets/{index-De-dCl_t.css → index-BB_TF4Cq.css} +1 -1
  47. package/dist-ui/assets/index-BdwcYEzG.js +532 -0
  48. package/dist-ui/assets/{index-DnEQCOGR.js.map → index-BdwcYEzG.js.map} +1 -1
  49. package/dist-ui/index.html +2 -2
  50. package/dist-ui-lib/index.js +69 -65
  51. package/dist-ui-lib/index.js.map +1 -1
  52. package/package.json +2 -2
  53. package/src/core/control-panel.ts +2 -1
  54. package/src/core/gateway.ts +28 -29
  55. package/src/plugins/auth/adapters/supertokens-adapter.ts +45 -0
  56. package/src/plugins/auth/env-config.ts +3 -0
  57. package/src/plugins/auth/types.ts +9 -0
  58. package/src/plugins/maintenance-plugin.ts +1 -1
  59. package/src/plugins/postgres-plugin.test.ts +78 -0
  60. package/src/plugins/postgres-plugin.ts +39 -11
  61. package/src/plugins/rate-limit/env-config.ts +4 -4
  62. package/src/utils/index.ts +1 -0
  63. package/src/utils/url.test.ts +43 -0
  64. package/src/utils/url.ts +21 -0
  65. package/ui/src/dashboard/widgets/DatabaseOperationsWidget.tsx +17 -8
  66. package/dist-ui/assets/index-DnEQCOGR.js +0 -532
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qwickapps/server",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Plugin-based application server framework for building websites, APIs, admin dashboards, and full-stack products",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -139,7 +139,7 @@
139
139
  },
140
140
  "repository": {
141
141
  "type": "git",
142
- "url": "https://github.com/qwickapps/server.git"
142
+ "url": "git+https://github.com/qwickapps/server.git"
143
143
  },
144
144
  "homepage": "https://github.com/qwickapps/server#readme",
145
145
  "publishConfig": {
@@ -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);
@@ -72,6 +72,51 @@ export function supertokensAdapter(config: SupertokensAdapterConfig): AuthAdapte
72
72
  recipeList.push(EmailPassword.default.init());
73
73
  }
74
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
+
75
120
  // Add ThirdParty recipe if any social providers configured
76
121
  if (config.socialProviders) {
77
122
  // Build provider configurations using Supertokens ProviderInput type
@@ -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
@@ -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 };
@@ -819,7 +819,7 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
819
819
  // Execute Payload migration command
820
820
  const { spawn } = await import('child_process');
821
821
 
822
- migrationProcess = spawn('npx', ['payload', 'migrate', '--force-accept-warning'], {
822
+ migrationProcess = spawn('pnpm', ['exec', 'payload', 'migrate', '--force-accept-warning'], {
823
823
  cwd: process.cwd(),
824
824
  env: {
825
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';
@@ -0,0 +1,43 @@
1
+ import { sanitizeUrl } from './url.js';
2
+
3
+ describe('sanitizeUrl', () => {
4
+ it('allows https URLs', () => {
5
+ expect(sanitizeUrl('https://example.com')).toBe('https://example.com');
6
+ });
7
+
8
+ it('allows http URLs', () => {
9
+ expect(sanitizeUrl('http://example.com/path')).toBe('http://example.com/path');
10
+ });
11
+
12
+ it('allows relative URLs starting with /', () => {
13
+ expect(sanitizeUrl('/relative/path')).toBe('/relative/path');
14
+ });
15
+
16
+ it('blocks javascript: protocol', () => {
17
+ expect(sanitizeUrl('javascript:alert(document.cookie)')).toBe('#');
18
+ });
19
+
20
+ it('blocks javascript: with uppercase', () => {
21
+ expect(sanitizeUrl('JavaScript:alert(1)')).toBe('#');
22
+ });
23
+
24
+ it('blocks data: protocol', () => {
25
+ expect(sanitizeUrl('data:text/html,<h1>test</h1>')).toBe('#');
26
+ });
27
+
28
+ it('blocks vbscript: protocol', () => {
29
+ expect(sanitizeUrl('vbscript:msgbox(1)')).toBe('#');
30
+ });
31
+
32
+ it('returns fallback for empty string', () => {
33
+ expect(sanitizeUrl('')).toBe('#');
34
+ });
35
+
36
+ it('returns custom fallback when provided', () => {
37
+ expect(sanitizeUrl('javascript:alert(1)', '/safe')).toBe('/safe');
38
+ });
39
+
40
+ it('returns fallback for malformed URLs', () => {
41
+ expect(sanitizeUrl('not a url at all')).toBe('#');
42
+ });
43
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Sanitizes a URL to prevent javascript: and other dangerous protocol injections
3
+ * when interpolating user-supplied URLs into HTML href attributes.
4
+ *
5
+ * Allows: http:, https:, and relative URLs (starting with /).
6
+ * Returns fallback for any other protocol (e.g., javascript:, data:, vbscript:).
7
+ */
8
+ export function sanitizeUrl(url: string, fallback = '#'): string {
9
+ if (!url) return fallback;
10
+ // Allow relative URLs
11
+ if (url.startsWith('/')) return url;
12
+ try {
13
+ const parsed = new URL(url);
14
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
15
+ return url;
16
+ }
17
+ return fallback;
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ }
@@ -33,6 +33,7 @@ interface DatabaseStatus {
33
33
  user?: string;
34
34
  host?: string;
35
35
  port?: number;
36
+ managed?: boolean;
36
37
  errorMessage?: string;
37
38
  autoInitializeEnabled: boolean;
38
39
  adminCredentialsProvided: boolean;
@@ -360,6 +361,12 @@ export const DatabaseOperationsWidget: React.FC<DatabaseOperationsWidgetProps> =
360
361
  </Typography>
361
362
  </Box>
362
363
 
364
+ {status.managed && (
365
+ <Alert severity="info" sx={{ mt: 2 }}>
366
+ Managed database (Neon / Supabase). Delete and recreate is disabled — manage your database through the provider dashboard.
367
+ </Alert>
368
+ )}
369
+
363
370
  {!status.connected && !operating && (
364
371
  <Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
365
372
  <Button
@@ -370,14 +377,16 @@ export const DatabaseOperationsWidget: React.FC<DatabaseOperationsWidgetProps> =
370
377
  >
371
378
  Initialize Database
372
379
  </Button>
373
- <Button
374
- variant="contained"
375
- color="error"
376
- onClick={() => startOperation('recreate')}
377
- size="small"
378
- >
379
- Recreate Database
380
- </Button>
380
+ {!status.managed && (
381
+ <Button
382
+ variant="contained"
383
+ color="error"
384
+ onClick={() => startOperation('recreate')}
385
+ size="small"
386
+ >
387
+ Recreate Database
388
+ </Button>
389
+ )}
381
390
  </Box>
382
391
  )}
383
392
  </CardContent>