Plugin Runtime Architecture
Deep dive into Jiffoo Mall's plugin runtime architecture and multi-instance system
Plugin Runtime Architecture
This document provides a comprehensive overview of Jiffoo Mall's plugin runtime architecture, including the multi-instance model, database schema, lifecycle management, and security boundaries.
Overview
Jiffoo Mall's plugin system uses a two-tier architecture:
- Plugin Package (
PluginInstall) - The installed plugin code and metadata - Plugin Instance (
PluginInstallation) - Runtime configurations that can be created multiple times per plugin
This separation enables powerful multi-tenancy and configuration management capabilities.
Multi-Instance Architecture
Conceptual Model
┌─────────────────────────────────────────────────────────────────┐
│ Plugin Runtime System │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Plugin Package (PluginInstall) │
│ └─ slug: "stripe" │
│ └─ source: vendors/jiffoo-extensions-official/extensions/plugins/stripe/ │
│ └─ metadata: name, version, author, permissions │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Plugin Instances (PluginInstallation) │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ Instance 1: "default" │ │
│ │ - id: c1a2b3c4-... │ │
│ │ - enabled: true │ │
│ │ - config: { apiKey: "pk_live_..." } │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ Instance 2: "stripe-us" │ │
│ │ - id: d5e6f7g8-... │ │
│ │ - enabled: true │ │
│ │ - config: { apiKey: "pk_live_us_..." } │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ Instance 3: "stripe-eu" │ │
│ │ - id: h9i0j1k2-... │ │
│ │ - enabled: false │ │
│ │ - config: { apiKey: "pk_live_eu_..." } │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘Key Concepts
- Plugin Package: One installation per plugin (identified by
slug) - Plugin Instance: Multiple configurations per plugin (identified by
instanceKeyorinstallationId) - Default Instance: Every plugin has a reserved
defaultinstance (cannot be deleted) - Instance Isolation: Each instance has its own configuration and enabled state
Database Schema
PluginInstall Table (Package Level)
Stores the installed plugin code and metadata.
| Field | Type | Description |
|---|---|---|
id | CUID | Primary key |
slug | String (unique) | Plugin identifier (format: ^[a-z][a-z0-9-]{0,30}[a-z0-9]$) |
name | String | Plugin display name |
version | String | Semantic version (format: MAJOR.MINOR.PATCH) |
author | String? | Plugin author |
authorUrl | String? | Author website URL |
description | String? | Plugin description |
category | String? | Plugin category (payment, shipping, etc.) |
runtimeType | String | internal-fastify or external-http |
entryModule | String? | Entry point for internal-fastify (e.g., server/index.js) |
externalBaseUrl | String? | Base URL for external-http |
source | String | Installation source (builtin, local-zip, official-market) |
installPath | String? | Relative path to plugin directory |
zipHash | String? | SHA-256 hash for idempotent installation |
manifestJson | String? | Full manifest.json content |
permissions | String? | JSON array of declared permissions |
deletedAt | DateTime? | Soft delete timestamp |
installedAt | DateTime | Installation timestamp |
updatedAt | DateTime | Last update timestamp |
Constraints:
slugmust be unique- Cannot delete or update soft-deleted plugins (
deletedAt != null) - Built-in plugins cannot be uninstalled
PluginInstallation Table (Instance Level)
Stores runtime instances and their configurations.
| Field | Type | Description |
|---|---|---|
id | UUID | Installation ID (globally unique, non-reusable) |
pluginSlug | String | Foreign key to PluginInstall.slug |
instanceKey | String | Instance identifier (format: ^[a-z0-9-]{1,32}$) |
enabled | Boolean | Whether this instance is enabled |
configJson | String? | Instance-specific configuration (max 64KB) |
grantedPermissions | String? | JSON array of granted permissions |
deletedAt | DateTime? | Soft delete timestamp |
createdAt | DateTime | Creation timestamp |
updatedAt | DateTime | Last update timestamp |
Constraints:
(pluginSlug, instanceKey)must be uniqueinstanceKey === 'default'instance cannot be deleted- Soft-deleted instances cannot be recreated (must use different
instanceKey) - Cascade delete when plugin package is deleted
Relationship Diagram
PluginInstall (1) ───< (N) PluginInstallation
slug pluginSlug (FK)
instanceKey
Example:
stripe ────< stripe + default
└───< stripe + stripe-us
└───< stripe + stripe-euPlugin Lifecycle
Installation Flow
┌─────────────────────────────────────────────────────────────────┐
│ Plugin Installation │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ Upload ZIP file │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Calculate SHA-256 │
│ Check for duplicate │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Extract to temp │
│ directory │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Validate manifest │
│ - schemaVersion: 1 │
│ - slug format │
│ - semver format │
│ - runtimeType │
│ - permissions array │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Security checks │
│ - Zip slip │
│ - File type filter │
│ - Size limits │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Move to extensions/ │
│ plugins/{slug}/ │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Create PluginInstall│
│ record in database │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Create "default" │
│ PluginInstallation │
│ (enabled: true) │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Plugin Ready │
└─────────────────────┘Instance Lifecycle
┌─────────────────────────────────────────────────────────────────┐
│ Instance Lifecycle │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ Create Instance │
│ POST /instances │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Validate input │
│ - instanceKey │
│ - config size/depth │
│ - plugin exists │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Store in database │
│ - Generate UUID │
│ - Set enabled=true │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Instance Active │
└─────────────────────┘
│
┌─────────┴─────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Enable/Disable │ │ Update Config │
│ (toggle flag) │ │ (replace JSON) │
└──────────────────┘ └──────────────────┘
│ │
└─────────┬─────────┘
▼
┌─────────────────────┐
│ Soft Delete │
│ (set deletedAt) │
│ - Disable instance │
│ - Cannot recreate │
└─────────────────────┘Uninstallation Flow
┌─────────────────────────────────────────────────────────────────┐
│ Plugin Uninstallation │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ Request uninstall │
│ DELETE /plugins/ │
│ {slug} │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Verify not builtin │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Transaction: │
│ 1. Set PluginInstall│
│ .deletedAt │
│ 2. Disable all │
│ instances │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Clear cache │
│ - plugins:installed │
│ - plugins:config:* │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Files preserved │
│ (safety measure) │
└─────────────────────┘Configuration Validation Rules
Slug Validation
Format: ^[a-z][a-z0-9-]{0,30}[a-z0-9]$
Rules:
- Length: 2-32 characters
- Start: Must begin with a lowercase letter
- Characters: Lowercase letters, numbers, hyphens only
- End: Must end with letter or number (not hyphen)
Valid Examples:
stripepayment-gatewaymy-plugin-v2
Invalid Examples:
Stripe(uppercase)-stripe(starts with hyphen)stripe-(ends with hyphen)a(too short)
Instance Key Validation
Format: ^[a-z0-9-]{1,32}$
Rules:
- Length: 1-32 characters
- Characters: Lowercase letters, numbers, hyphens only
- Reserved:
defaultcannot be used for custom instances
Valid Examples:
default(reserved)productionstripe-us-westtest-1
Invalid Examples:
Production(uppercase)stripe_us(underscore)my-very-long-instance-key-name-exceeds-limit(too long)
Version Validation
Format: Strict semantic versioning (MAJOR.MINOR.PATCH)
Rules:
- Must follow
^\d+\.\d+\.\d+$pattern - No pre-release tags
- No build metadata
Valid Examples:
1.0.02.3.1510.0.1
Invalid Examples:
1.0(missing patch)1.0.0-beta(pre-release tag)v1.0.0(prefix not allowed)
Instance Config Validation
Size Limits:
- Maximum JSON size: 64KB (65,536 bytes)
- Maximum nesting depth: 10 layers
Format Rules:
- Must be a JSON object (not array or primitive)
nullorundefinedis allowed (means no config)
Example Valid Config:
{
"apiKey": "pk_live_...",
"webhookSecret": "whsec_...",
"settings": {
"currency": "USD",
"captureMethod": "automatic",
"metadata": {
"source": "jiffoo-mall"
}
}
}Validation Logic:
// 1. Check if object (not array)
if (typeof config !== 'object' || Array.isArray(config)) {
throw Error('Config must be an object');
}
// 2. Check size (64KB limit)
const jsonStr = JSON.stringify(config);
const sizeBytes = Buffer.byteLength(jsonStr, 'utf-8');
if (sizeBytes > 65536) {
throw Error('Config exceeds 64KB');
}
// 3. Check depth (10 layers max)
function getDepth(obj, currentDepth = 1) {
if (typeof obj !== 'object' || obj === null) {
return currentDepth;
}
const depths = Object.values(obj).map(val =>
getDepth(val, currentDepth + 1)
);
return depths.length > 0 ? Math.max(...depths) : currentDepth;
}
if (getDepth(config) > 10) {
throw Error('Config nesting exceeds 10 layers');
}Security Boundaries
Installation Security
┌─────────────────────────────────────────────────────────────────┐
│ Security Layers │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: ZIP Security │
│ ├─ Zip Slip Protection (path traversal prevention) │
│ ├─ File Type Whitelist │
│ │ └─ Allowed: .js, .json, .woff, .woff2, .css, .html │
│ │ └─ Blocked: .exe, .sh, .bat, .dll, .so, etc. │
│ ├─ Size Limits │
│ │ └─ Max font file: 2MB each │
│ │ └─ Max total fonts: 20MB │
│ │ └─ Max font count: 50 files │
│ └─ Idempotent Install (SHA-256 hash check) │
│ │
│ Layer 2: Manifest Validation │
│ ├─ Schema version check (must be 1) │
│ ├─ Required fields validation │
│ ├─ Format validation (slug, version, permissions) │
│ └─ Runtime type validation │
│ │
│ Layer 3: Database Integrity │
│ ├─ Unique constraints (slug, pluginSlug+instanceKey) │
│ ├─ Foreign key constraints (cascade delete) │
│ ├─ Soft delete (prevents accidental data loss) │
│ └─ Non-reusable IDs (installationId uniqueness) │
│ │
│ Layer 4: Runtime Isolation │
│ ├─ Permission boundaries (granted vs declared) │
│ ├─ Instance-level access control (enabled flag) │
│ ├─ Config sandboxing (64KB + 10 layer limits) │
│ └─ Gateway routing isolation │
│ │
└─────────────────────────────────────────────────────────────────┘Permission Model
Declared Permissions (in manifest.json):
{
"permissions": [
"database:read",
"database:write",
"api:external"
]
}Granted Permissions (per instance):
{
"grantedPermissions": [
"database:read",
"database:write"
]
}Rules:
- Plugins declare needed permissions in manifest
- Instances can be granted a subset of declared permissions
- Gateway enforces granted permissions at runtime
- Permissions cannot exceed declared set
Soft Delete Model
Purpose: Prevent accidental data loss and maintain audit trail
Implementation:
- Plugin Package: Setting
deletedAthides plugin from UI/gateway - Plugin Instance: Setting
deletedAtprevents recreation - Files: Preserved on disk for safety (manual cleanup required)
Uninstallation Rules:
// 1. Cannot uninstall built-in plugins
if (plugin.source === 'builtin') {
throw Error('Cannot uninstall built-in plugins');
}
// 2. Set deletedAt on package (transaction)
await prisma.$transaction([
prisma.pluginInstall.update({
where: { slug },
data: { deletedAt: new Date() }
}),
// 3. Disable all instances
prisma.pluginInstallation.updateMany({
where: { pluginSlug: slug, deletedAt: null },
data: { enabled: false }
})
]);
// 4. Files remain at extensions/plugins/{slug}/
// (manual cleanup or re-install possible)Gateway Routing
Request Flow
┌─────────────────────────────────────────────────────────────────┐
│ Gateway Request Flow │
└─────────────────────────────────────────────────────────────────┘
│
▼
/api/extensions/plugin/{slug}/api/{path}
│
▼
┌─────────────────────┐
│ 1. Parse slug │
│ from URL │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 2. Get instance │
│ (default or ID) │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 3. Check enabled │
│ flag │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 4. Verify plugin │
│ exists on disk │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 5. Inject headers │
│ - X-Tenant-ID │
│ - X-Installation-│
│ ID │
│ - X-Platform- │
│ Signature │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 6. Route to plugin │
│ - internal: load │
│ module │
│ - external: HTTP │
│ proxy │
└─────────────────────┘Instance Resolution
The gateway resolves instances in the following order:
- Explicit ID: If request includes
X-Installation-IDheader - Default Instance: Falls back to
instanceKey="default" - Not Found: Returns 404 if no instance found or enabled
API Reference
Plugin Management
// Get plugin package
PluginManagementService.getPluginPackage(slug: string): Promise<PluginInstall | null>
// Get all installed packages
PluginManagementService.getAllPluginPackages(): Promise<PluginInstall[]>
// Uninstall plugin (soft delete)
PluginManagementService.uninstallPlugin(slug: string): Promise<void>Instance Management
// Get instance by ID
PluginManagementService.getInstanceById(id: string): Promise<PluginInstallation | null>
// Get instance by key
PluginManagementService.getInstanceByKey(slug: string, key: string): Promise<PluginInstallation | null>
// Get default instance
PluginManagementService.getDefaultInstance(slug: string): Promise<PluginInstallation | null>
// Get all instances for plugin
PluginManagementService.getPluginInstances(slug: string): Promise<PluginInstallation[]>
// Create new instance
PluginManagementService.createInstance(
slug: string,
instanceKey: string,
options?: {
enabled?: boolean;
config?: Record<string, unknown>;
grantedPermissions?: string[];
}
): Promise<PluginInstallation>
// Update instance
PluginManagementService.updateInstance(
installationId: string,
updates: {
enabled?: boolean;
config?: Record<string, unknown>;
grantedPermissions?: string[];
}
): Promise<PluginInstallation>
// Delete instance (soft delete, cannot delete "default")
PluginManagementService.deleteInstance(installationId: string): Promise<PluginInstallation>Runtime Checks
// Check if instance is enabled
PluginManagementService.isPluginEnabled(
slug: string,
instanceKeyOrId: string
): Promise<boolean>
// Get instance configuration
PluginManagementService.getInstanceConfig(
slug: string,
instanceKeyOrId: string
): Promise<Record<string, unknown> | null>Best Practices
For Plugin Developers
- Always validate config schema in your plugin code
- Handle missing config gracefully (use defaults)
- Never hardcode credentials (use instance config)
- Implement health checks (monitor instance status)
- Log instance-specific errors (include installationId)
For Platform Administrators
- Use meaningful instance keys (e.g.,
stripe-production,stripe-test) - Disable unused instances instead of deleting (preserves audit trail)
- Test config changes in disabled instances before enabling
- Monitor plugin disk usage (soft-deleted files remain on disk)
- Review granted permissions regularly (principle of least privilege)
Multi-Instance Use Cases
Regional Configuration:
stripe + default → US payments
stripe + stripe-eu → EU payments (SCA compliance)
stripe + stripe-apac → APAC payments (local methods)Environment Isolation:
emailer + default → Production SMTP
emailer + staging → Staging SMTP
emailer + development → Development (console logging)A/B Testing:
analytics + default → GA4 (primary)
analytics + mixpanel → Mixpanel (secondary)
analytics + test → Debug analytics (disabled)Related Documentation
- Plugin Development Guide - How to build plugins
- Theme Development Guide - How to build themes
- API Reference - Full API documentation