This document details the architecture and implementation of the plugin registry system within the Funish Vertex project. The system is designed to be lightweight, robust, and fully compatible with the Better Auth plugin ecosystem.
- Simplicity: We have removed all custom dependency management and complex logic. The registry is now a simple, in-memory map of available plugins.
- Better Auth Alignment: The system is designed to work with BetterAuth, not replace its core functionality. Our registry acts as a metadata layer for discovering and instantiating plugins, which are then managed by the Better Auth runtime.
- Focus on Metadata: Our
PluginDefinitioninterface serves as a descriptor for plugins, decorating them with metadata (like name, category, version, config schema) needed for platform features like a plugin marketplace UI, without interfering with the plugin's core logic. - Multi-Tenant Support: The registry works seamlessly with the Tenant Plugin to provide organization-scoped plugin configurations and data isolation, while keeping core plugins organization-agnostic.
PluginDefinition: The central interface. It defines the metadata for a plugin and contains optionalserverPluginandclientPluginfactory functions.OrganizationPluginConfig: Defines the shape of the configuration for a specific plugin enabled for a specific organization.PluginCategory&PluginStatus: Enums used for organizing and filtering plugins in the UI.Tenant: Simplified interface for organization context, extending Better Auth's organization schema with tenant-specific fields.ContextWithOrganization: Helper type for type-safe context access.
-
PluginRegistryClass: A singleton class (globalPluginRegistry) that holds all availablePluginDefinitions in aMap.register(plugin): Adds a newPluginDefinitionto the registry.get(id): Retrieves a plugin definition by its ID.getAll(): Returns an array of all registered plugin definitions.filter(...): Filters plugins based on category, status, or a search term.getBetterAuthPlugins(configs): Takes an array ofOrganizationPluginConfigand returns{ server, client }arrays of instantiated Better Auth plugins, ready to be passed to the mainbetterAuthinstance.
-
registryPlugin: A built-in Better Auth plugin that exposes the registry'sfilterandgetcapabilities via API endpoints (/registry/pluginsand/registry/plugins/:id). This allows a frontend application to query for available plugins.
registerBuiltinPlugins(): A simple function that imports the definitions of all our built-in plugins (likestoragePluginDefinition,tenantPluginDefinition) and callsglobalPluginRegistry.register()on them.initializePluginSystem(): CallsregisterBuiltinPlugins(). This is executed automatically on server startup.
The Tenant Plugin is a special plugin that provides multi-tenant capabilities to all other plugins:
The Tenant Plugin uses Better Auth's native hooks system to inject organization context:
// Tenant Plugin with Better Auth hooks
export const tenantPlugin = (): BetterAuthPlugin => {
return {
id: "tenant",
// Extend Better Auth's organization schema
schema: {
organization: {
fields: {
dbSchema: { type: "string", required: false },
authConfig: { type: "string", required: false }, // JSON string
pluginConfigs: { type: "string", required: false }, // JSON string
customDomain: { type: "string", required: false },
},
},
},
// Use Better Auth hooks for organization context injection
hooks: {
before: [
{
matcher: (ctx) => ctx.path.startsWith("/api/v1/"),
handler: async (ctx) => {
const orgId = ctx.headers["x-organization-id"];
if (orgId) {
// Validate organization and inject into context
const organization = await getOrganizationFromDatabase(orgId);
if (organization) {
(ctx.context as any).organization = organization;
}
}
return ctx;
},
},
],
},
endpoints: {
getConfig: "/tenant/config",
updateConfig: "/tenant/config",
initializeTenant: "/tenant/initialize",
getStats: "/tenant/stats",
},
};
};
// Helper function for type-safe organization access
export const getOrganizationFromContext = (ctx: {
context: unknown;
}): Tenant | undefined => {
const context = ctx.context as { organization?: Tenant };
return context.organization;
};Storage Isolation:
// Storage Plugin using organization context
const orgStorage = ctx.getOrganizationStorage?.() || defaultStorage;
// This creates a prefixed storage: `org_{organizationId}:${userKey}`
await orgStorage.setItem("user-avatar.jpg", data);
// Actual storage key: "org_acme-corp:user-avatar.jpg"Database Isolation:
// Database Plugin using organization schema
const orgDb = ctx.getOrganizationDatabase?.() || defaultDb;
// This connects to organization-specific schema: `org_acme_corp_a1b2c3d4`
await orgDb.selectFrom("users").selectAll().execute();The Tenant Plugin uses a header-based approach for organization identification:
POST /api/v1/storage/my-file
Headers:
X-Organization-ID: acme-corp
Authorization: Bearer org_acme-corp_api_12345678
Content-Type: application/jsonValidation Process:
- Extract organization ID from
X-Organization-IDheader - Extract API key from
Authorizationheader - Validate that the API key belongs to the specified organization
- Inject organization context into the request
Core plugins remain organization-agnostic while gaining multi-tenant capabilities using the helper function:
// Storage Plugin - uses helper function for organization context
export const storagePlugin = (options) => {
return {
id: "storage",
endpoints: {
getItem: createAuthEndpoint(
"/storage/:key",
{
method: "GET",
use: [sessionMiddleware],
},
async (ctx) => {
// Get organization context using helper function
const organization = getOrganizationFromContext(ctx);
const organizationId = organization?.id;
if (!organizationId) {
return ctx.json(
{ error: "Organization context required" },
{ status: 400 },
);
}
// Create organization-scoped storage
const storage = await getOrganizationStorage(organizationId);
const value = await storage.getItem(ctx.params.key);
return ctx.json({ value });
},
),
setItem: createAuthEndpoint(
"/storage/:key",
{
method: "POST",
use: [sessionMiddleware],
},
async (ctx) => {
const organization = getOrganizationFromContext(ctx);
const organizationId = organization?.id;
if (!organizationId) {
return ctx.json(
{ error: "Organization context required" },
{ status: 400 },
);
}
const storage = await getOrganizationStorage(organizationId);
await storage.setItem(ctx.params.key, ctx.body);
return ctx.json({ success: true });
},
),
},
};
};interface OrganizationPluginConfig {
id: string; // Unique config ID
organizationId: string; // Organization identifier
pluginId: string; // Plugin identifier
enabled: boolean; // Whether plugin is enabled
config: Record<string, any>; // Plugin-specific configuration
createdAt: Date;
updatedAt: Date;
}// Get enabled plugins for an organization
export const getEnabledPlugins = async (organizationId: string) => {
const configs = await db
.selectFrom("organizationPluginConfig")
.selectAll()
.where("organizationId", "=", organizationId)
.where("enabled", "=", true)
.execute();
return globalPluginRegistry.getBetterAuthPlugins(configs);
};getEnabledPlugins(organizationId, configs): A helper function that filters a list of all organization plugin configs to find the ones enabled for a specific organization, then uses the registry'sgetBetterAuthPluginsmethod to get the instantiated plugins.createPluginDefinition(config): A utility function to create standardized plugin definitions with proper typing.getOrganizationFromContext(ctx): Helper function for type-safe organization context access across all plugins.zodToAuthPluginSchema(zodSchema, options): Utility function to convert Zod schemas to Better Auth compatible format.
Our plugin system includes a powerful type generation mechanism that automatically creates TypeScript types from Zod schema definitions and converts them to Better Auth compatible format.
All plugins should define their database schema using Zod for type inference, then use our utility function to convert to Better Auth format:
import { z } from "zod";
import { zodToAuthPluginSchema } from "../registry/utils";
// 1. Define Zod schema for type inference
export const myPluginDataSchema = z.object({
id: z.string(),
organizationId: z.string(), // Always required for multi-tenant support
name: z.string(),
description: z.string().optional(),
isActive: z.boolean(),
metadata: z.string().optional(), // JSON string
createdAt: z.date(),
updatedAt: z.date().nullable().optional(),
});
// 2. Export TypeScript types using z.infer
export type MyPluginData = z.infer<typeof myPluginDataSchema>;
// 3. Use in Better Auth plugin schema
export const myPlugin = (options: MyPluginOptions = {}) => {
return {
id: "my-plugin",
schema: {
// Convert Zod schema to Better Auth format
myPluginData: zodToAuthPluginSchema(myPluginDataSchema),
// Extend existing tables
organization: {
fields: {
myPluginConfig: {
type: "string",
required: false,
},
},
},
},
// ... rest of plugin implementation
} satisfies BetterAuthPlugin;
};Supported Zod Types:
z.string()→"string"z.number()→"number"z.boolean()→"boolean"z.date()→"date"z.enum()→"string"(stored as string)z.optional()→required: falsez.nullable()→required: false
Important Notes:
- Always include
organizationIdfield for multi-tenant support - The utility function automatically adds foreign key references for
organizationId - Complex types (arrays, objects) are stored as JSON strings (
"string"type)
The zodToAuthPluginSchema utility function is located in packages/vertex/src/plugins/registry/utils.ts:
/**
* Utility function to convert Zod schema to Better Auth schema format.
* This eliminates the need for duplicate schema definitions.
*/
export function zodToAuthPluginSchema<T extends z.ZodRawShape>(zodSchema: z.ZodObject<T>) {
// Automatically converts Zod types to Better Auth field definitions
// Handles optional fields, foreign keys, and type mapping
return { fields: /* ... converted fields ... */ };
}Benefits:
- No Duplication: Define schema once in Zod, use everywhere
- Type Safety: Full TypeScript inference from Zod schemas
- Consistency: Automatic conversion ensures compatible formats
- Maintainability: Single source of truth for schema definitions
Complex Schema with References:
export const userProfileSchema = z.object({
id: z.string(),
organizationId: z.string(),
userId: z.string(), // Will automatically get foreign key reference
profileData: z.string(), // JSON string for complex data
preferences: z.string().optional(),
isPublic: z.boolean(),
createdAt: z.date(),
updatedAt: z.date().nullable().optional(),
});
// The utility function will generate:
// {
// fields: {
// id: { type: "string", required: true },
// organizationId: { type: "string", required: true, references: { model: "organization", field: "id" } },
// userId: { type: "string", required: true },
// profileData: { type: "string", required: true },
// preferences: { type: "string", required: false },
// isPublic: { type: "boolean", required: true },
// createdAt: { type: "date", required: true },
// updatedAt: { type: "date", required: false },
// }
// }Runtime Validation with Zod:
// Use the same Zod schema for API validation
const createDataEndpoint = createAuthEndpoint(
"/my-plugin/data",
{
method: "POST",
body: myPluginDataSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
}),
},
async (ctx) => {
// ctx.body is now fully type-safe and validated
const { organizationId, name, description, isActive } = ctx.body;
// Database operations with type safety
const newRecord: Partial<MyPluginData> = {
id: generateId(),
organizationId,
name,
description: description || null,
isActive,
createdAt: new Date(),
updatedAt: new Date(),
};
// ... implementation
},
);Development Workflow:
# 1. Update Zod schema in your plugin
# 2. Generate TypeScript types and Better Auth schema
npx @better-auth/cli generate
# 3. Apply database migrations
npx @better-auth/cli migrate
# 4. Test your changes
pnpm testSchema Evolution:
// Before (v1)
export const myDataSchemaV1 = z.object({
id: z.string(),
organizationId: z.string(),
name: z.string(),
createdAt: z.date(),
});
// After (v2) - Adding new optional field
export const myDataSchemaV2 = z.object({
id: z.string(),
organizationId: z.string(),
name: z.string(),
description: z.string().optional(), // New optional field
createdAt: z.date(),
updatedAt: z.date().nullable().optional(), // New optional field
});
// The utility handles backward compatibility automatically
export type MyData = z.infer<typeof myDataSchemaV2>;This approach ensures that all plugins follow consistent patterns while maintaining full type safety and eliminating code duplication.
The new architecture follows a clean, decoupled flow:
- Initialization: On server start,
initializePluginSystemis called, registering all built-inPluginDefinitions into theglobalPluginRegistry. - Discovery (Optional): A frontend admin panel can call the
/registry/pluginsendpoint (provided byregistryPlugin) to get a list of all available plugins and render a marketplace or settings UI. - Configuration: An organization admin enables and configures plugins via the UI. These settings are saved as
OrganizationPluginConfigrecords in the database (this logic is handled by the main application, not the registry). - Instantiation: When a request for a specific organization arrives, the main application:
a. Fetches all
OrganizationPluginConfigrecords from the database. b. CallsgetEnabledPlugins(orgId, configs). c. This utility filters the configs for the current organization and passes them toglobalPluginRegistry.getBetterAuthPlugins(). d. The registry iterates through the enabled plugins, finds theirPluginDefinition, calls theserverPluginfactory function with the organization's specific config, and returns an array of fully instantiated plugins. - Execution: This array of plugins is passed to the main
betterAuth({ plugins: [...] })instance for that request, which then handles everything else (routing, hooks, database access via its adapter, etc.).
Plugins should not contain organization-specific logic directly:
// ✅ Good - Organization-agnostic
export const storagePlugin = (options) => {
return {
id: "storage",
endpoints: {
upload: createAuthEndpoint(
"/storage/upload",
{ method: "POST" },
async (ctx) => {
// Use organization context if available, fallback to default
const storage =
ctx.getOrganizationStorage?.() || createDefaultStorage(options);
// ... plugin logic
},
),
},
};
};
// ❌ Bad - Organization-specific logic in plugin
export const storagePlugin = (options) => {
return {
id: "storage",
endpoints: {
upload: createAuthEndpoint(
"/storage/upload",
{ method: "POST" },
async (ctx) => {
// Don't hardcode organization logic
const orgId = ctx.headers.get("X-Organization-ID");
const storage = createOrgStorage(orgId);
// ...
},
),
},
};
};Use the helper function for organization-scoped resource access:
// Available through helper function
import { getOrganizationFromContext, type Tenant } from "../tenant";
// In plugin endpoints
const organization = getOrganizationFromContext(ctx);
const organizationId = organization?.id;
if (!organizationId) {
return ctx.json({ error: "Organization context required" }, { status: 400 });
}
// Access organization data
const orgConfig = organization.pluginConfigs
? JSON.parse(organization.pluginConfigs)
: {};
const pluginConfig = orgConfig[pluginId] || {};Design plugin configurations to be organization-specific and stored in the organization's pluginConfigs field:
export const myPluginDefinition = createPluginDefinition({
id: "my-plugin",
configSchema: {
type: "object",
properties: {
// Organization-specific settings
maxItems: { type: "number", default: 1000 },
allowedFileTypes: { type: "array", items: { type: "string" } },
customDomain: { type: "string", optional: true },
},
},
serverPlugin: (config) => myPlugin(config),
});
// Plugin implementation accessing organization config
export const myPlugin = (options) => {
return {
id: "my-plugin",
endpoints: {
someEndpoint: createAuthEndpoint(
"/my-plugin/action",
{ method: "POST", use: [sessionMiddleware] },
async (ctx) => {
const organization = getOrganizationFromContext(ctx);
const orgConfig = organization?.pluginConfigs
? JSON.parse(organization.pluginConfigs)
: {};
const pluginConfig = orgConfig["my-plugin"] || {};
// Use plugin config for organization-specific behavior
const maxItems =
pluginConfig.maxItems || options.defaultMaxItems || 100;
// ... rest of implementation
},
),
},
};
};This design is simple, robust, and aligns perfectly with Better Auth's intended use, allowing for easy integration of both official and custom plugins while maintaining complete organization isolation.