@qwickapps/server
v1.9.0
Published
Plugin-based application server framework for building websites, APIs, admin dashboards, and full-stack products
Maintainers
Readme
@qwickapps/server
A flexible, pluggable control panel framework for QwickApps services. Provides a web-based dashboard with authentication, health monitoring, and extensible plugin architecture.
Features
- Gateway Pattern: Production-ready gateway that proxies API requests while keeping control panel always responsive
- Configurable Mount Paths: Control panel mounts at
/cpanelby default, reserving root for frontend apps - Route Guards: Unified authentication system supporting Basic Auth, Supabase OAuth, and Auth0
- Plugin System: Extensible architecture for adding custom functionality
- Health Monitoring: Built-in health check management with customizable checks
- Frontend App Support: Handle root path with redirect, static files, or landing page
- Theming: Customizable branding and styling
What's New in v1.9.0
- Custom Auth Path: Auth plugin now supports
SUPERTOKENS_API_BASE_PATHfor custom auth endpoint routing - Dashboard Fix: Fixed "Unable to load auth status" on the control panel dashboard
- Seed Reliability: Fixed seed execution failures in Docker production and stuck UI state after errors
See CHANGELOG.md for full release history.
What's New in v1.7.0
- Tenants Plugin - Complete multi-tenancy system with data isolation and organization management
- Support for multiple tenant types: user (single-user), organization, group, department
- PostgreSQL store with auto-created tables and foreign key CASCADE delete
- Global roles: owner, admin, member, viewer
- REST API with 10 endpoints for CRUD, search, and membership management
- UI Components: Full tenant management page with create/edit/search/filter capabilities
- Member management: Add/remove members, change roles, invite via email
- Statistics dashboard showing total tenants by type
- Plugin Composition - Auto-tenant creation on user signup
- Users plugin detects tenants plugin and auto-creates personal tenant for new users
- Graceful degradation: Works independently or together
- Loose coupling: No forced dependencies between plugins
- Comprehensive Testing - 60 unit tests, 11 integration tests, 20 E2E tests for tenants plugin
See CHANGELOG.md for full release history.
Installation
pnpm add @qwickapps/serverQuick Start
import { createControlPanel, createHealthPlugin } from '@qwickapps/server';
const controlPanel = createControlPanel({
config: {
productName: 'My Service',
port: 3101,
version: '1.0.0',
mountPath: '/cpanel',
guard: {
type: 'basic',
username: 'admin',
password: process.env.ADMIN_PASSWORD!,
realm: 'My Service Control Panel',
excludePaths: ['/api/health'],
},
},
plugins: [
createHealthPlugin({
checks: [
{
name: 'database',
type: 'http',
url: 'http://localhost:5432/health',
interval: 10000,
},
],
}),
],
});
await controlPanel.start();
console.log(`Control panel running at http://localhost:3101/cpanel`);Gateway Pattern
For production deployments, use createGateway to run a gateway that:
- Serves the control panel UI (always responsive, even if the API crashes)
- Proxies API requests to an internal service
- Handles graceful error responses when the internal service is down
- Supports maintenance mode with customizable status pages
- Shows service unavailable pages when mounted apps are unreachable
import { createGateway, createHealthPlugin } from '@qwickapps/server';
import { createApp } from './app.js';
const gateway = createGateway(
{
productName: 'My Service',
gatewayPort: 3101, // Public port (control panel)
servicePort: 3100, // Internal port (API)
controlPanelPath: '/cpanel',
controlPanelGuard: {
type: 'basic',
username: 'admin',
password: process.env.ADMIN_PASSWORD!,
realm: 'My Service Control Panel',
excludePaths: ['/health', '/api/v1'],
},
proxyPaths: ['/api/v1'],
plugins: [
createHealthPlugin({
checks: [
{ name: 'api', type: 'http', url: 'http://localhost:3100/health' },
],
}),
],
},
// Service factory - creates the internal API service
async (port) => {
const app = createApp();
const server = app.listen(port);
return {
app,
server,
shutdown: async () => { server.close(); },
};
}
);
await gateway.start();Gateway Architecture
Internet → Gateway (3101, public) → API Service (3100, internal)
↓
Control Panel UI (/cpanel)
/cpanel/api/health
/cpanel/api/diagnosticsThe gateway is always responsive even if the internal API service crashes, allowing you to view diagnostics and error information.
Mounted Apps with Maintenance Mode
Mount frontend apps or proxy services with full maintenance and fallback support:
const gateway = createGateway({
// ... base config
mountedApps: [
{
path: '/app',
name: 'Main App',
type: 'proxy',
target: 'http://localhost:4000',
maintenance: {
enabled: false, // Toggle to enable maintenance mode
title: 'Scheduled Maintenance',
message: 'We are upgrading our systems.',
expectedBackAt: '2 hours', // or ISO date, or "soon"
contactUrl: 'https://status.example.com',
bypassPaths: ['/app/health', '/app/api/status'],
},
fallback: {
title: 'Service Unavailable',
message: 'The application is temporarily unavailable.',
showRetry: true,
autoRefresh: 30, // seconds
},
},
{
path: '/docs',
name: 'Documentation',
type: 'static',
staticPath: './docs-dist',
},
],
});Maintenance and Fallback Configuration
MaintenanceConfig - Shown when maintenance mode is enabled:
| Property | Type | Description |
|----------|------|-------------|
| enabled | boolean | Enable/disable maintenance mode |
| title | string | Page title (default: "Under Maintenance") |
| message | string | Custom message to display |
| expectedBackAt | string | ETA: ISO date, relative time ("2 hours"), or "soon" |
| contactUrl | string | Link to status page or contact |
| bypassPaths | string[] | Paths that bypass maintenance (e.g., health checks) |
FallbackConfig - Shown when the proxied service is unreachable:
| Property | Type | Description |
|----------|------|-------------|
| title | string | Page title (default: "Service Unavailable") |
| message | string | Custom message to display |
| showRetry | boolean | Show retry button (default: true) |
| autoRefresh | number | Auto-refresh countdown in seconds (default: 30) |
Both pages feature modern, responsive designs with automatic dark mode support.
Configuration
ControlPanelConfig
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| productName | string | Yes | Name displayed in the dashboard |
| port | number | Yes | Port to run the control panel on |
| version | string | No | Product version to display |
| mountPath | string | No | Path to mount control panel (default: /cpanel) |
| guard | RouteGuardConfig | No | Authentication guard configuration |
| branding | object | No | Logo, primary color, favicon |
| cors | object | No | CORS origins configuration |
| links | array | No | Quick links for the dashboard |
| skipBodyParserPaths | string[] | No | Paths to skip body parsing (for proxy) |
| logoUrl | string | No | Custom logo URL for the landing page |
Route Guards
The unified guard system provides authentication for the control panel. All guards support excludePaths to allow unauthenticated access to specific routes.
No Authentication
guard: { type: 'none' }Basic Auth
guard: {
type: 'basic',
username: 'admin',
password: process.env.ADMIN_PASSWORD!,
realm: 'Control Panel',
excludePaths: ['/api/health'],
}Supabase OAuth
guard: {
type: 'supabase',
supabaseUrl: process.env.SUPABASE_URL!,
supabaseAnonKey: process.env.SUPABASE_ANON_KEY!,
excludePaths: ['/api/health'],
}Auth0 OpenID Connect
Requires express-openid-connect as a peer dependency.
guard: {
type: 'auth0',
domain: process.env.AUTH0_DOMAIN!,
clientId: process.env.AUTH0_CLIENT_ID!,
clientSecret: process.env.AUTH0_CLIENT_SECRET!,
baseUrl: 'https://myapp.example.com',
secret: process.env.AUTH0_SECRET!,
routes: {
login: '/login',
logout: '/logout',
callback: '/callback',
},
excludePaths: ['/api/health'],
}Mount Paths
By default, the control panel mounts at /cpanel, reserving the root path (/) for frontend applications:
// Control panel at /cpanel
config: {
mountPath: '/cpanel', // Dashboard at /cpanel, API at /cpanel/api/*
}
// Or mount at root (legacy behavior)
config: {
mountPath: '/', // Dashboard at /, API at /api/*
}Frontend App Plugin
Handle the root path when control panel is mounted elsewhere:
import { createFrontendAppPlugin } from '@qwickapps/server';
// Redirect to external URL
createFrontendAppPlugin({
redirectUrl: 'https://myapp.example.com',
})
// Serve static files
createFrontendAppPlugin({
staticPath: './dist',
})
// Show landing page with links
createFrontendAppPlugin({
landingPage: {
title: 'My Service',
heading: 'Welcome',
description: 'Select a destination',
links: [
{ label: 'Control Panel', url: '/cpanel' },
{ label: 'API Docs', url: '/docs' },
],
},
})API Authentication (M2M)
Enable machine-to-machine authentication for your API routes using bearer tokens. This is ideal for service-to-service communication.
Prerequisites
API authentication requires the rate-limit plugin:
import { createRateLimitPlugin, postgresRateLimitStore, getPostgres } from '@qwickapps/server';
plugins: [
// PostgreSQL plugin (required by rate-limit)
{
plugin: createPostgresPlugin({
url: process.env.DATABASE_URL,
}),
},
// Rate-limit plugin (required by API keys auth)
{
plugin: createRateLimitPlugin({
store: postgresRateLimitStore({
pool: () => getPostgres().getPool(),
}),
defaults: {
maxRequests: 1000,
windowMs: 60000, // 1 minute
},
}),
},
]Enabling Authentication on Routes
Routes can opt-in to M2M authentication by setting auth: { required: true }:
import { createControlPanel } from '@qwickapps/server';
const controlPanel = createControlPanel({
config: { /* ... */ },
plugins: [
{
plugin: {
name: 'my-api',
async start({ registry }) {
// Authenticated route - requires API key
registry.addRoute({
method: 'post',
path: '/api/jobs/schedule',
pluginId: 'my-api',
auth: { required: true }, // Enable authentication
handler: async (req, res) => {
// req.apiKey contains verified key info
res.json({ success: true });
},
});
// Public route - no authentication required
registry.addRoute({
method: 'get',
path: '/api/status',
pluginId: 'my-api',
// No auth property = unauthenticated
handler: async (req, res) => {
res.json({ status: 'ok' });
},
});
},
},
},
],
});Generating API Keys
Create M2M API keys for your clients:
import { createApiKey } from '@qwickapps/server';
// Generate a new M2M API key
const apiKey = await createApiKey({
user_id: '00000000-0000-0000-0000-000000000000', // Service user ID
name: 'production-worker',
key_type: 'm2m', // Machine-to-machine
scopes: [], // No scopes required in Phase 1A
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days
});
console.log(`API Key: ${apiKey.plaintext_key}`);
// Output: qk_live_BdrbOkdpYidqkx0vkFnulmSUR4eYFLbAGndrPOAOxM8Important: The plaintext_key is only returned once during creation. Store it securely.
Using API Keys
Clients pass the API key in the Authorization header:
# cURL example
curl -X POST https://api.example.com/api/jobs/schedule \
-H "Authorization: Bearer qk_live_BdrbOkdpYidqkx0vkFnulmSUR4eYFLbAGndrPOAOxM8" \
-H "Content-Type: application/json" \
-d '{"type": "llm-completion", "model": "gpt-3.5-turbo"}'// Node.js example
const response = await fetch('https://api.example.com/api/jobs/schedule', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'llm-completion',
model: 'gpt-3.5-turbo',
}),
});API Key Management
import { listApiKeys, getApiKey, updateApiKey, deleteApiKey } from '@qwickapps/server';
// List all keys for a user
const keys = await listApiKeys(userId);
// Get specific key
const key = await getApiKey(userId, keyId);
// Update key (name, scopes, expiration, active status)
await updateApiKey(userId, keyId, {
name: 'updated-name',
is_active: false, // Disable key
});
// Delete (revoke) key
await deleteApiKey(userId, keyId);Security Best Practices
- Store keys securely: Use environment variables or secrets management
- Rotate keys regularly: Generate new keys and delete old ones every 90 days
- Use HTTPS: Always transmit keys over encrypted connections
- Monitor usage: Track
last_used_atto identify unused keys - Set expiration: Configure
expires_atfor automatic key expiry - Revoke compromised keys: Delete keys immediately if leaked
Error Handling
Authentication failures return HTTP 401:
{
"success": false,
"error": "Invalid, expired, or inactive API key"
}Rate limit exceeded returns HTTP 429:
{
"success": false,
"error": "Rate limit exceeded"
}Plugins
Built-in Plugins
Health Plugin
Monitors service health with configurable checks:
import { createHealthPlugin } from '@qwickapps/server';
createHealthPlugin({
checks: [
{
name: 'api',
type: 'http',
url: 'http://localhost:3000/health',
interval: 10000,
timeout: 5000,
},
{
name: 'custom',
type: 'custom',
check: async () => ({
healthy: true,
details: { message: 'All good' },
}),
interval: 30000,
},
],
});Diagnostics Plugin
import { createDiagnosticsPlugin } from '@qwickapps/server';
createDiagnosticsPlugin({});Config Plugin
import { createConfigPlugin } from '@qwickapps/server';
createConfigPlugin({
show: ['NODE_ENV', 'PORT', 'DATABASE_URL'],
mask: ['SECRET', 'PASSWORD', 'KEY', 'TOKEN'],
});Logs Plugin
import { createLogsPlugin } from '@qwickapps/server';
createLogsPlugin({
sources: [
{ name: 'app', type: 'file', path: './logs/app.log' },
{ name: 'error', type: 'file', path: './logs/error.log' },
],
});PostgreSQL Plugin
Provides connection pooling, transactions, and health checks for PostgreSQL databases.
import { createPostgresPlugin, getPostgres } from '@qwickapps/server';
// Register the plugin
createPostgresPlugin({
connectionString: process.env.DATABASE_URL,
// Or individual options:
// host: 'localhost',
// port: 5432,
// database: 'mydb',
// user: 'postgres',
// password: 'secret',
maxConnections: 20,
healthCheckInterval: 30000,
});
// Use in your code
const pg = getPostgres();
const result = await pg.query('SELECT * FROM users WHERE id = $1', [userId]);
// With transactions
await pg.withTransaction(async (client) => {
await client.query('INSERT INTO orders ...');
await client.query('UPDATE inventory ...');
});Exports:
createPostgresPlugin(config)- Create and register the plugingetPostgres(name?)- Get a PostgreSQL instance (throws if not registered)hasPostgres(name?)- Check if an instance is registeredPostgresInstance- TypeScript type for the instance
Cache Plugin
Redis-based caching with key prefixing, TTL support, and pattern operations.
import { createCachePlugin, getCache } from '@qwickapps/server';
// Register the plugin
createCachePlugin({
url: process.env.REDIS_URL,
// Or individual options:
// host: 'localhost',
// port: 6379,
// password: 'secret',
keyPrefix: 'myapp:',
defaultTTL: 3600, // 1 hour default
healthCheckInterval: 30000,
});
// Use in your code
const cache = getCache();
// Basic operations
await cache.set('user:123', userData, 600); // TTL in seconds
const user = await cache.get('user:123');
await cache.delete('user:123');
// Pattern operations
const keys = await cache.keys('user:*');
await cache.deletePattern('session:*');
// Stats and maintenance
const stats = await cache.getStats();
await cache.flush(); // Clear all keys with prefixExports:
createCachePlugin(config)- Create and register the plugingetCache(name?)- Get a cache instance (throws if not registered)hasCache(name?)- Check if an instance is registeredCacheInstance- TypeScript type for the instance
Auth Plugin
Pluggable authentication with support for multiple providers via the adapter pattern.
Zero-Config Environment Setup
The simplest way to configure auth is via environment variables:
import { createAuthPluginFromEnv } from '@qwickapps/server';
// Auto-configures based on AUTH_ADAPTER env var
const authPlugin = createAuthPluginFromEnv();
// With overrides
const authPlugin = createAuthPluginFromEnv({
excludePaths: ['/health', '/metrics'],
authRequired: true,
});Environment Variables:
# General (required to enable auth)
AUTH_ADAPTER=supertokens|auth0|supabase|basic
AUTH_REQUIRED=true # Default: true
AUTH_EXCLUDE_PATHS=/health,/metrics # Comma-separated
AUTH_DEBUG=false
# Supertokens
SUPERTOKENS_CONNECTION_URI=http://localhost:3567
SUPERTOKENS_APP_NAME=MyApp
SUPERTOKENS_API_DOMAIN=http://localhost:3000
SUPERTOKENS_WEBSITE_DOMAIN=http://localhost:3000
SUPERTOKENS_API_KEY= # Optional, for managed service
SUPERTOKENS_GOOGLE_CLIENT_ID= # Optional social providers
SUPERTOKENS_GOOGLE_CLIENT_SECRET=
SUPERTOKENS_GITHUB_CLIENT_ID=
SUPERTOKENS_GITHUB_CLIENT_SECRET=
# Auth0
AUTH0_DOMAIN=myapp.auth0.com
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_BASE_URL=http://localhost:3000
AUTH0_SECRET= # Session encryption secret
AUTH0_AUDIENCE= # Optional, for API access tokens
AUTH0_SCOPES=openid,profile,email # Default scopes
# Supabase
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=
# Basic Auth
BASIC_AUTH_USERNAME=admin
BASIC_AUTH_PASSWORD=
BASIC_AUTH_REALM=Protected # Default: ProtectedPlugin States:
| State | Condition | Behavior |
|-------|-----------|----------|
| disabled | AUTH_ADAPTER not set | No auth middleware |
| enabled | Valid configuration | Auth active |
| error | Invalid configuration | Disabled with error details |
Check Auth Status:
import { getAuthStatus } from '@qwickapps/server';
const status = getAuthStatus();
// { state: 'enabled', adapter: 'supertokens', config: {...} }Programmatic Configuration
For more control, configure adapters directly in code:
import { createAuthPlugin, auth0Adapter, basicAdapter } from '@qwickapps/server';
// Auth0 with RBAC and domain restrictions
createAuthPlugin({
adapter: auth0Adapter({
domain: process.env.AUTH0_DOMAIN!,
clientId: process.env.AUTH0_CLIENT_ID!,
clientSecret: process.env.AUTH0_CLIENT_SECRET!,
baseUrl: 'https://myapp.example.com',
secret: process.env.SESSION_SECRET!,
audience: process.env.AUTH0_AUDIENCE, // For API access tokens
allowedRoles: ['admin', 'support'], // RBAC filtering
allowedDomains: ['@company.com'], // Domain whitelist
exposeAccessToken: true, // For downstream API calls
}),
excludePaths: ['/health', '/api/public'],
});
// Basic auth fallback
createAuthPlugin({
adapter: basicAdapter({
username: 'admin',
password: process.env.ADMIN_PASSWORD!,
}),
});Available Adapters:
supertokensAdapter- Supertokens email/password + social logins (requiressupertokens-node)auth0Adapter- Auth0 OIDC (requiresexpress-openid-connect)supabaseAdapter- Supabase JWT validationbasicAdapter- HTTP Basic authentication
Helper Functions:
import { isAuthenticated, getAuthenticatedUser, getAccessToken } from '@qwickapps/server';
// In your route handlers
if (isAuthenticated(req)) {
const user = getAuthenticatedUser(req);
// { id, email, name, picture, emailVerified, roles }
const accessToken = getAccessToken(req);
// Use for downstream API calls
}Middleware Helpers:
import { requireAuth, requireRoles, requireAnyRole } from '@qwickapps/server';
// Require authentication
app.get('/admin', requireAuth(), (req, res) => { ... });
// Require specific roles (all required)
app.get('/admin/users', requireRoles('admin', 'user-manager'), (req, res) => { ... });
// Require any of the roles
app.get('/dashboard', requireAnyRole('admin', 'editor', 'viewer'), (req, res) => { ... });Users Plugin
Storage-agnostic user management with ban support.
import { createUsersPlugin, postgresUserStore, getPostgres } from '@qwickapps/server';
import { Pool } from 'pg';
// Create with PostgreSQL storage
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
createUsersPlugin({
store: postgresUserStore({
pool,
usersTable: 'users',
bansTable: 'user_bans',
autoCreateTables: true,
}),
bans: {
enabled: true,
supportTemporary: true, // Enable expiring bans
onBan: async (user, ban) => {
// Notify external systems, revoke sessions, etc.
console.log(`User ${user.email} banned: ${ban.reason}`);
},
onUnban: async (user) => {
console.log(`User ${user.email} unbanned`);
},
},
api: {
prefix: '/api/users',
crud: true, // GET/POST/PUT/DELETE /api/users
search: true, // GET /api/users?q=...
bans: true, // Ban management endpoints
},
});REST API Endpoints:
| Endpoint | Method | Description |
|----------|--------|-------------|
| /api/users | GET | List/search users |
| /api/users | POST | Create user |
| /api/users/:id | GET | Get user by ID |
| /api/users/:id | PUT | Update user |
| /api/users/:id | DELETE | Delete user |
| /api/users/bans | GET | List active bans |
| /api/users/:id/ban | GET | Get user's ban status |
| /api/users/:id/ban | POST | Ban user |
| /api/users/:id/ban | DELETE | Unban user |
| /api/users/:id/bans | GET | Get user's ban history |
Helper Functions:
import { getUserById, getUserByEmail, isUserBanned, findOrCreateUser } from '@qwickapps/server';
// Get user
const user = await getUserById('user-123');
const userByEmail = await getUserByEmail('[email protected]');
// Check ban status
const banned = await isUserBanned('user-123');
// Find or create from auth provider
const user = await findOrCreateUser({
email: '[email protected]',
name: 'Test User',
external_id: 'auth0|12345',
provider: 'auth0',
});Email Ban Support (for auth-only scenarios):
For cases where you don't store users locally but need to ban by email:
import { isEmailBanned, getEmailBan, banEmail, unbanEmail } from '@qwickapps/server';
// Check if email is banned
const banned = await isEmailBanned('[email protected]');
// Get ban details
const ban = await getEmailBan('[email protected]');
// Ban an email
await banEmail({
email: '[email protected]',
reason: 'Spam activity',
banned_by: '[email protected]',
duration: 86400, // 24 hours (optional, null = permanent)
});
// Unban an email
await unbanEmail({
email: '[email protected]',
unbanned_by: '[email protected]',
note: 'Cleared after review',
});Tenants Plugin
Multi-tenant data isolation and organization management with role-based access control.
import { createTenantsPlugin, postgresTenantStore } from '@qwickapps/server';
import { Pool } from 'pg';
// Create with PostgreSQL storage
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
createTenantsPlugin({
store: postgresTenantStore({
pool,
tenantsTable: 'tenants',
membershipsTable: 'tenant_memberships',
autoCreateTables: true,
}),
apiPrefix: '/api/tenants',
apiEnabled: true,
});Tenant Types:
user- Single-user personal tenant (auto-created on user signup)organization- Multi-user organizationgroup- Team or project groupdepartment- Company department
Global Roles:
owner- Full control, can delete tenantadmin- Manage members and settingsmember- Standard accessviewer- Read-only access
Features:
- Auto-creates owner membership when tenant is created
- Foreign key constraints with CASCADE delete for data integrity
- UPSERT behavior for membership updates (ON CONFLICT DO UPDATE)
- Search with filtering by type, owner, and query string
- Pagination and sorting support
Authentication & Authorization:
All tenant API routes require authentication. Authorization rules enforce tenant-level access control:
- List tenants: Users see only their own tenants
- Get tenant: User must be a member
- Create tenant: Auto-added as owner
- Update tenant: Admin or owner role required
- Delete tenant: Owner role only
- Manage members: Admin or owner role required
REST API Endpoints:
| Endpoint | Method | Description | Authorization |
|----------|--------|-------------|---------------|
| /api/tenants | GET | List/search user's tenants | Authenticated (returns user's tenants) |
| /api/tenants | POST | Create new tenant | Authenticated (becomes owner) |
| /api/tenants/:id | GET | Get tenant by ID | Member of tenant |
| /api/tenants/:id | PUT | Update tenant | Admin or owner |
| /api/tenants/:id | DELETE | Delete tenant (cascades) | Owner only |
| /api/tenants/user/:userId | GET | Get tenants for user | Own tenants only |
| /api/tenants/:tenantId/members | GET | List tenant members | Member of tenant |
| /api/tenants/:tenantId/members | POST | Add member to tenant | Admin or owner |
| /api/tenants/:tenantId/members/:userId | PUT | Update member role | Admin or owner |
| /api/tenants/:tenantId/members/:userId | DELETE | Remove member | Admin or owner |
Helper Functions:
import { getTenantStore } from '@qwickapps/server';
const store = getTenantStore();
// Get tenant
const tenant = await store.getById('tenant-123');
// Get all tenants for a user with membership info
const tenants = await store.getTenantsForUser('user-123');
// Create organization tenant
const org = await store.create({
name: 'Acme Corp',
type: 'organization',
owner_id: 'user-123',
metadata: { industry: 'technology' },
});
// Add team member
await store.addMember({
tenant_id: org.id,
user_id: 'user-456',
role: 'admin',
});
// Search tenants
const results = await store.search({
query: 'acme',
type: 'organization',
page: 1,
limit: 20,
sortBy: 'name',
sortOrder: 'asc',
});Plugin Composition:
The Tenants plugin integrates seamlessly with the Users plugin through the plugin registry:
// Users plugin auto-detects tenants plugin
createUsersPlugin({ store: userStore });
createTenantsPlugin({ store: tenantStore });
// When a user is created, a personal tenant is auto-created
// Works gracefully whether tenants plugin is present or notPreferences Plugin
Per-user preferences storage with PostgreSQL Row-Level Security (RLS) for data isolation.
import { createPreferencesPlugin, postgresPreferencesStore } from '@qwickapps/server';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
createPreferencesPlugin({
store: postgresPreferencesStore({
pool,
tableName: 'user_preferences',
autoCreateTables: true,
enableRLS: true, // Enable Row-Level Security
}),
defaults: {
theme: 'system',
notifications: {
email: true,
push: true,
},
},
api: {
prefix: '/preferences',
enabled: true,
},
});Note: The Preferences plugin requires the Users plugin to be loaded first, as it creates a foreign key reference to the users table.
REST API Endpoints:
| Endpoint | Method | Description |
|----------|--------|-------------|
| /api/preferences | GET | Get current user's preferences (merged with defaults) |
| /api/preferences | PUT | Update preferences (deep merge with existing) |
| /api/preferences | DELETE | Reset preferences to defaults |
Helper Functions:
import {
getPreferences,
updatePreferences,
deletePreferences,
getDefaultPreferences,
deepMerge,
} from '@qwickapps/server';
// Get user preferences (merged with defaults)
const prefs = await getPreferences('user-123');
// Update preferences (deep merge - preserves nested values)
const updated = await updatePreferences('user-123', {
theme: 'dark',
notifications: { email: false },
});
// Result: { theme: 'dark', notifications: { email: false, push: true } }
// Reset to defaults
await deletePreferences('user-123');
// Get configured defaults
const defaults = getDefaultPreferences();
// Deep merge utility (exported for custom use)
const merged = deepMerge(baseObject, overrides);Security Features:
- PostgreSQL Row-Level Security ensures users can only access their own preferences
- Transaction-safe RLS context for connection pooling compatibility
- Input validation: 100KB size limit, 10-level nesting depth
- Foreign key to users table with
ON DELETE CASCADE
Rate Limit Plugin
API rate limiting with multiple strategies, PostgreSQL persistence, and Redis caching.
import {
createRateLimitPlugin,
postgresRateLimitStore,
rateLimitMiddleware,
} from '@qwickapps/server';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Add plugin to gateway
createRateLimitPlugin({
store: postgresRateLimitStore({
pool,
tableName: 'rate_limits',
autoCreateTables: true,
enableRLS: true,
}),
defaults: {
windowMs: 60000, // 1 minute window
maxRequests: 100, // 100 requests per window
strategy: 'sliding-window', // or 'fixed-window', 'token-bucket'
},
cache: {
type: 'auto', // 'redis' | 'memory' | 'auto' (uses Redis if available)
},
cleanup: {
enabled: true,
intervalMs: 300000, // Clean up expired limits every 5 minutes
},
});
// Use middleware on routes
app.use('/api', rateLimitMiddleware());
// Per-route configuration
app.post('/api/chat', rateLimitMiddleware({
windowMs: 60000,
max: 50, // Lower limit for expensive operations
keyGenerator: (req) => `chat:${req.user?.id}`,
}));
// Dynamic limits based on user tier
app.use(rateLimitMiddleware({
max: (req) => req.user?.tier === 'premium' ? 1000 : 50,
}));Rate Limiting Strategies:
| Strategy | Description | Best For |
|----------|-------------|----------|
| sliding-window | Smooth rate limiting with weighted overlap | Most use cases (default) |
| fixed-window | Simple discrete time windows | High performance, simple needs |
| token-bucket | Allows bursts while maintaining average rate | APIs needing burst capacity |
REST API Endpoints:
| Endpoint | Method | Description |
|----------|--------|-------------|
| /api/rate-limit/status | GET | Get rate limit status for current user |
| /api/rate-limit/status/:key | GET | Get status for a specific key |
| /api/rate-limit/clear/:key | DELETE | Clear a rate limit (requires auth) |
Programmatic API:
import {
isLimited,
checkLimit,
incrementLimit,
getRemainingRequests,
getLimitStatus,
clearLimit,
} from '@qwickapps/server';
// Check if rate limited
const limited = await isLimited('user:123:api');
// Get full status without incrementing
const status = await checkLimit('user:123:api');
// { limited: false, current: 45, limit: 100, remaining: 55, resetAt: 1702656000, retryAfter: 30 }
// Increment and get status
const result = await incrementLimit('user:123:api');
// Clear limit (e.g., after CAPTCHA)
await clearLimit('user:123:api');Response Headers:
RateLimit-Limit: 100
RateLimit-Remaining: 55
RateLimit-Reset: 1702656000
Retry-After: 30 (only when limited)Security Features:
- PostgreSQL Row-Level Security isolates rate limits per user
- Redis caching with in-memory fallback for high availability
- Fail-open by default (allows requests on errors)
Environment Variable Configuration:
Use createRateLimitPluginFromEnv() for zero-config setup:
import { createRateLimitPluginFromEnv, createPostgresPlugin } from '@qwickapps/server';
// Requires postgres-plugin to be registered first
const gateway = createGateway({
plugins: [
createPostgresPlugin({ /* ... */ }),
createRateLimitPluginFromEnv(), // Reads config from env vars
],
});| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| RATE_LIMIT_ENABLED | true | Enable rate limiting |
| RATE_LIMIT_STRATEGY | sliding-window | Strategy: sliding-window, fixed-window, token-bucket |
| RATE_LIMIT_WINDOW_MS | 60000 | Window size in milliseconds |
| RATE_LIMIT_MAX_REQUESTS | 100 | Maximum requests per window |
| RATE_LIMIT_CACHE_TYPE | auto | Cache type: redis, memory, auto |
| RATE_LIMIT_CLEANUP_ENABLED | true | Enable cleanup job |
| RATE_LIMIT_CLEANUP_INTERVAL_MS | 300000 | Cleanup interval (5 min) |
| RATE_LIMIT_API_ENABLED | true | Enable status API endpoints |
| RATE_LIMIT_API_PREFIX | /rate-limit | API route prefix |
| RATE_LIMIT_TABLE_NAME | rate_limits | PostgreSQL table name |
| RATE_LIMIT_ENABLE_RLS | true | Enable Row-Level Security |
| RATE_LIMIT_AUTO_CREATE_TABLES | true | Auto-create tables |
| RATE_LIMIT_DEBUG | false | Enable debug logging |
Config status endpoint: GET /api/rate-limit/config/status
Runtime Configuration UI:
The Rate Limit plugin includes a Control Panel page for live configuration:
- Navigate to
/rate-limitsin the Control Panel - Edit default window size, max requests, and strategy
- Toggle cleanup job on/off and adjust interval
- Changes take effect immediately (no restart required)
Runtime Config API:
# Get current config
curl http://localhost:3000/api/rate-limit/config
# Update config at runtime
curl -X PUT http://localhost:3000/api/rate-limit/config \
-H "Content-Type: application/json" \
-d '{"maxRequests": 200, "windowMs": 120000, "strategy": "token-bucket"}'Creating Custom Plugins
import type { ControlPanelPlugin, PluginContext } from '@qwickapps/server';
const myPlugin: ControlPanelPlugin = {
name: 'my-plugin',
order: 10,
routes: [
{
method: 'get',
path: '/my-plugin/status',
handler: async (req, res) => {
res.json({ status: 'ok' });
},
},
],
initialize: async (context: PluginContext) => {
context.logger.debug('My plugin initialized');
},
shutdown: async () => {
// Cleanup
},
};API Reference
createControlPanel(options)
Creates a new control panel instance.
Returns: ControlPanelInstance
interface ControlPanelInstance {
app: Express.Application;
start: () => Promise<void>;
stop: () => Promise<void>;
registerPlugin: (plugin: ControlPanelPlugin) => void;
getHealthStatus: () => Promise<HealthStatus>;
}createRouteGuard(config)
Creates authentication middleware from guard configuration.
import { createRouteGuard } from '@qwickapps/server';
const guard = createRouteGuard({
type: 'basic',
username: 'admin',
password: 'secret',
});
app.use(guard);isAuthenticated(req)
Check if current request is authenticated.
import { isAuthenticated } from '@qwickapps/server';
if (isAuthenticated(req)) {
// User is authenticated
}getAuthenticatedUser(req)
Get authenticated user information.
import { getAuthenticatedUser } from '@qwickapps/server';
const user = getAuthenticatedUser(req);
// { id: string; email?: string; name?: string; }Built-in Routes
Routes are mounted at the configured mountPath (default /cpanel):
| Route | Method | Description |
|-------|--------|-------------|
| {mountPath}/ | GET | Dashboard UI |
| {mountPath}/api/health | GET | Aggregated health status |
| {mountPath}/api/diagnostics | GET | System diagnostics |
| {mountPath}/api/config | GET | Configuration (if plugin enabled) |
| {mountPath}/api/logs | GET | Log viewer (if plugin enabled) |
License
This software is licensed under the PolyForm Shield License 1.0.0.
What This Means
Permitted Uses:
- Internal business applications
- Learning and educational projects
- Non-competitive commercial applications
- Academic research and teaching
- Building applications that use this control panel framework
Prohibited Uses:
- Creating competing control panel frameworks
- Building competing developer tools or dashboards
- Reselling or redistributing as a competing product
- Reverse engineering to create competitive products
For full license terms, see PolyForm Shield 1.0.0.
For commercial licensing options, contact [email protected].
Copyright (c) 2025 QwickApps. All rights reserved.
