Documentation
BusinessOS Docs
Platform documentation for builders and marketplace developers.
Documentation
Platform documentation for builders and marketplace developers.
The authoritative module contract and platform rules.
This document defines the core requirements and specifications for building modules on the BusinessOS platform. All modules—whether built internally or by third-party developers—must adhere to these requirements to ensure compatibility, security, and a consistent user experience.
Modules are the building blocks of BusinessOS functionality. Each module is a self-contained unit that can provide:
Every module must be designed with a strict separation of operational layers:
Project Owner / Admin (Backoffice Layer): where builders configure and operate the module inside a specific project.
End User / Public (Frontend Layer): where end-users consume what the project owner publishes on the project site.
Architectural rule: modules are infrastructure attached to a project. Project owners configure and deploy; end users consume.
Payments Core is the single billing authority of the platform.
Any module that requires payments, subscriptions, or monetization must integrate exclusively with the payments-core module for:
Strict prohibition: direct integrations with Stripe (or any payment provider) inside feature modules are not allowed.
Feature modules may define what requires payment, but must delegate how billing works to Payments Core.
Each project may select exactly one installed module as its core module. The core module drives the default end-user experience and ensures the project site feels like a cohesive product rather than disconnected features.
project_module_installations.config.isCoreModule = true (only one installation per project may have this flag)./): chooses an industry-appropriate template based on coreModuleId (e.g. Courses vs Digital Products)./portal, /courses, /products).siteNavigation items for the header (and set requiresAuth when needed).sitePages for public entry points (catalog/detail/learning/downloads), but do not render their own headers.Every module must follow this directory structure:
modules/
└── {module-name}/
├── package.json # Module package definition
├── tsconfig.json # TypeScript configuration
└── src/
├── index.ts # Module definition & registration
├── project/ # Project-owner (backoffice) layer
│ ├── api/ # Project-scoped admin API handlers
│ │ └── *.ts
│ └── pages/ # Project admin pages (rendered under /p/:slug/modules/:moduleId)
│ └── *.tsx
├── site/ # End-user/public layer (optional)
│ └── pages/ # Public site pages (rendered under /site/* via module router)
│ └── *.tsx
├── api/ # (Legacy/optional) tenant-level module API handlers
│ └── *.ts
├── pages/ # (Legacy/optional) tenant-level pages (avoid for new modules)
│ └── *.tsx
└── components/ # Reusable components
└── *.tsx
{
"name": "@v2/module-{name}",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@v2/modules": "workspace:*",
"@v2/database": "workspace:*"
},
"devDependencies": {
"@v2/tsconfig": "workspace:*",
"typescript": "^5.0.0"
}
}
{
"extends": "@v2/tsconfig/react-library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
All modules must implement the ModuleDefinition interface:
interface ModuleDefinition {
// REQUIRED
metadata: ModuleMetadata;
// OPTIONAL
// -----------------------------
// Project-owner (backoffice)
// -----------------------------
projectAdminNavigation?: ModuleNavItem[];
projectAdminPages?: Record<string, ModuleRenderable<ModulePageProps>>;
projectAdminApiRoutes?: ModuleApiRoute[];
projectSettingsPage?: ModuleRenderable<ModulePageProps>;
// -----------------------------
// End-user/public (site)
// -----------------------------
sitePages?: Record<string, ModuleRenderable<SiteModulePageProps>>;
siteApiRoutes?: SiteModuleApiRoute[]; // authenticated end-user APIs under /api/site/modules/{moduleId}
siteNavigation?: SiteNavItem[]; // end-user site header navigation items
// -----------------------------
// Legacy (avoid for new modules)
// -----------------------------
navigation?: ModuleNavItem[]; // tenant-level nav (not project-scoped)
apiRoutes?: ModuleApiRoute[]; // tenant-level APIs under /api/modules/{moduleId}
permissions?: ModulePermission[];
migrations?: ModuleMigration[];
pages?: Record<string, ModuleRenderable<ModulePageProps>>;
settingsPage?: ModuleRenderable<ModulePageProps>;
dashboardWidget?: ComponentType<{ context: ModuleContext }>; // legacy tenant-level widget (deprecated)
onEnable?: (tenantId: string) => Promise<void>;
onDisable?: (tenantId: string) => Promise<void>;
}
/p/:projectSlug/modules/:moduleId/.../api/projects/:projectId/modules/:moduleId/...sitePages, routed under the project host and rewritten to /site/... internally/api/site/modules/:moduleId/... (when using siteApiRoutes)The metadata object is required and describes your module:
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier (lowercase, kebab-case). Example: crm, invoicing, end-user-auth |
name | string | Human-readable name. Example: Customer Relationship Management |
description | string | Short description (1-2 sentences) |
version | string | Semantic version. Example: 1.0.0 |
| Field | Type | Description |
|---|---|---|
icon | string | SVG icon name (no emojis) |
category | ModuleCategoryType | Category for marketplace grouping |
requiredPlans | string[] | Plan IDs required to use this module |
dependencies | string[] | Other module IDs this module depends on |
defaultEnabled | boolean | Whether enabled by default for new tenants |
eventsEmitted | string[] | Events this module emits |
longDescription | string | Detailed description for marketplace |
features | string[] | Feature bullet points |
targetAudience | string | Who should use this module |
tags | string[] | Searchable tags |
setupRequired | boolean | Whether setup is needed before use |
author | string | Author/publisher name |
documentationUrl | string | Link to documentation |
previewImage | string | Preview image URL |
checklistItems | ModuleChecklistItem[] | Setup checklist items |
landingSection | ModuleLandingSection | Public site landing section |
providesSitePages | boolean | Whether module provides public site pages |
siteNavigation | SiteNavItem[] | End-user site header navigation items (built by the platform header) |
siteDashboardSections | SiteDashboardSectionDefinition[] | Legacy dashboard descriptors (deprecated; avoid for new modules) |
requiredSiteCapabilities | ModuleSiteThemeCapability[] | Required site template capabilities for end-user experience |
type ModuleCategoryType =
| 'foundation' // Core functionality (team, auth)
| 'operations' // Business operations (tasks, projects)
| 'sales' // Sales & CRM
| 'marketing' // Marketing tools
| 'finance' // Invoicing, payments
| 'automation' // Workflows, integrations
| 'analytics' // Reporting, dashboards
| 'ai' // AI-powered features
| 'developer' // Developer tools
| 'enterprise'; // Enterprise features
Modules must explicitly declare the end-user site capabilities they require so template compatibility can be enforced at runtime.
Use:
metadata: {
// ...
requiredSiteCapabilities: [
'layout.publicHeader',
'layout.workspaceContent',
'ui.navigation.moduleLinks',
'ui.surface.card',
'ui.form.controls',
'ui.feedback.error',
'ui.data.list',
'ui.data.keyValue',
],
}
ModuleSiteThemeCapabilityMODULE_SITE_THEME_CAPABILITIES@v2/modulesregisterModule()requiredSiteCapabilitiesModules can create database tables via migrations. All tables MUST implement Row-Level Security (RLS).
interface ModuleMigration {
name: string; // Unique name: '0001_create_contacts_table'
up: string; // SQL to run (up migration)
down?: string; // SQL to run (down migration) - optional
}
Every table created by a module MUST follow this pattern:
-- 1. Create table with tenant_id
CREATE TABLE IF NOT EXISTS {module}_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- ... other columns
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 2. Create indexes
CREATE INDEX IF NOT EXISTS idx_{module}_table_tenant
ON {module}_table(tenant_id);
-- 3. Enable RLS
ALTER TABLE {module}_table ENABLE ROW LEVEL SECURITY;
ALTER TABLE {module}_table FORCE ROW LEVEL SECURITY;
-- 4. Create RLS policies using current_tenant_id()
CREATE POLICY {module}_table_select ON {module}_table
FOR SELECT USING (tenant_id = current_tenant_id());
CREATE POLICY {module}_table_insert ON {module}_table
FOR INSERT WITH CHECK (tenant_id = current_tenant_id());
CREATE POLICY {module}_table_update ON {module}_table
FOR UPDATE USING (tenant_id = current_tenant_id());
CREATE POLICY {module}_table_delete ON {module}_table
FOR DELETE USING (tenant_id = current_tenant_id());
-- 5. Grant permissions to app_user role
GRANT SELECT, INSERT, UPDATE, DELETE ON {module}_table TO app_user;
For tables scoped to projects (not tenants), use this pattern:
CREATE TABLE IF NOT EXISTS {module}_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
-- ... other columns
);
-- RLS via project's tenant
CREATE POLICY {module}_table_select ON {module}_table
FOR SELECT USING (
project_id IN (
SELECT id FROM projects WHERE tenant_id = current_tenant_id()
)
);
Important: Migrations are executed in two phases. Also note that migrations are optional — UI-only modules may have zero migrations.
When a platform admin makes a module "available" in the admin panel, the module's migrations run globally:
Admin sets available=true → runGlobalModuleMigrations() → Tables created
CREATE TABLE IF NOT EXISTSmodule_schema_versions (canonical) and mirrored to platform_module_migrations for compatibilityWhen a user installs the module on their project:
User clicks "Install" → Module enabled for project → No migrations run
| Approach | Problem |
|---|---|
| ❌ Run migrations on user install | First user might see errors if migration fails |
| ✅ Run migrations when admin enables | Tables exist before any user tries to use them |
1. Developer creates end-user-auth module with migrations
2. Admin enables module in admin panel (available=true)
→ CREATE TABLE end_users (...)
→ CREATE TABLE end_user_sessions (...)
→ Tables now exist globally
3. User A installs module on Project X
→ No migrations, just enables feature
→ User A's end users stored in end_users table
4. User B installs module on Project Y
→ No migrations, just enables feature
→ User B's end users stored in same table, isolated by RLS
Modules can define three API surfaces:
projectAdminApiRoutes (required for backoffice operations)
/api/projects/{projectId}/modules/{moduleId}/{...path}siteApiRoutes (optional, end-user/project-site APIs)
/api/site/modules/{moduleId}/{...path}apiRoutes (legacy tenant-level module APIs)
/api/modules/{moduleId}/{...path}Some modules also provide end-user APIs for project subdomains (e.g., myproject.credsnet.com).
These endpoints are mounted at:
/api/site/modules/{moduleId}/{...path}
Modules declare these via siteApiRoutes (distinct from backoffice projectAdminApiRoutes).
The platform will automatically:
resolveProjectContext() pattern)project_session cookie)This makes it possible for modules to expose end-user data without hardcoding module logic into site pages.
/dashboard is now a compatibility redirect in template-first projects.
If you maintain legacy projects, modules can contribute dashboard sections by adding:
metadata.siteDashboardSections: a list of section descriptors (rendered by the platform dashboard)siteApiRoutes: endpoints that return user-specific section dataImportant (legacy mode): modules do not render their own dashboard UI. The dashboard owns the UI; modules provide only:
interface SiteDashboardSectionDefinition {
id: string;
title: string;
description?: string;
icon?: string; // SVG icon name
order?: number;
kind: 'accountSummary' | 'coursesProgressSummary' | 'productsDownloadsSummary' | 'keyValue';
endpoint: { method?: 'GET'; path: string }; // relative to /api/site/modules/{moduleId}
requiresAuth?: boolean;
}
interface ModuleApiRoute {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string; // Relative path, e.g., '/contacts/:id'
handler: (request: Request, context: ModuleContext) => Promise<Response>;
minimumRole?: 'owner' | 'admin' | 'member'; // REQUIRED in module route definitions
permissions?: string[]; // Optional metadata (must align with minimumRole)
}
projectAdminApiRoutes must explicitly set minimumRoleapiRoutes (if still used) must also set minimumRolemember for read/list/view endpointsadmin for create/update/delete/publish/configuration endpointsowner only for tenant-critical controlsROUTE_PERMISSION_CONFIG_ERROREvery handler receives a ModuleContext:
interface ModuleContext {
tenantId: string; // Current tenant ID
tenantSlug: string; // Current tenant slug
projectId?: string; // Current project ID (if applicable)
projectSlug?: string; // Current project slug
userId?: string; // Current user ID
userRole?: 'owner' | 'admin' | 'member';
config: Record<string, unknown>; // Module configuration
}
export async function handleGetContacts(
request: Request,
context: ModuleContext
): Promise<Response> {
const { tenantId } = context;
const contacts = await withTenantQuery(tenantId, (sql) => sql`
SELECT * FROM crm_contacts ORDER BY created_at DESC
`);
return Response.json({ contacts });
}
Modules define granular permissions that integrate with the platform's RBAC system.
Important:
minimumRole on each API route.permissions are still valuable for capability labeling, auditing, and policy intent.permissions aligned with route behavior, but do not use permissions alone as the only access contract.interface ModulePermission {
id: string; // Format: '{module}:{resource}:{action}'
name: string; // Human-readable name
description: string; // What this permission allows
}
Permission IDs must follow this format:
{module-id}:{resource}:{action}
Examples:
crm:contacts:readcrm:contacts:writecrm:contacts:deleteinvoicing:invoices:sendAction suffix guidance (must map to known actions if route-level inference is ever used):
read, list, viewwrite, create, update, delete, publish, manage, admin, configurepermissions: [
{
id: 'crm:contacts:read',
name: 'View Contacts',
description: 'Can view the list of contacts',
},
{
id: 'crm:contacts:write',
name: 'Manage Contacts',
description: 'Can create, edit, and delete contacts',
},
]
Modules can add items to the application sidebar:
interface ModuleNavItem {
label: string; // Display text
href: string; // Route path
icon?: string; // SVG icon name (no emojis)
permissions?: string[]; // Required permissions to see
children?: ModuleNavItem[]; // Nested items
}
Modules can contribute end-user navigation links for the project site header via siteNavigation.
interface SiteNavItem {
label: string; // Display label in the header
href: string; // Absolute site path (e.g. '/courses', '/products')
icon?: string; // SVG icon name
requiresAuth?: boolean; // Hide unless end-user is logged in
}
Rules:
siteNavigation should point to sitePages routes (public entry points), not internal /site/* paths.Pages are React components that receive ModulePageProps:
interface ModulePageProps {
context: ModuleContext;
params: Record<string, string>;
searchParams: Record<string, string>;
}
Legacy widget contract from pre-template-first architecture:
dashboardWidget?: ComponentType<{ context: ModuleContext }>;
Modules can define hooks that run when enabled/disabled:
// Called when module is enabled for a tenant
onEnable?: (tenantId: string) => Promise<void>;
// Called when module is disabled for a tenant
onDisable?: (tenantId: string) => Promise<void>;
Modules can contribute content to project public landing pages.
interface ModuleLandingSection {
title: string; // Section title
description: string; // Section description
features: string[]; // Feature bullet points
icon?: string; // SVG icon name
order?: number; // Display order (lower = first)
}
landingSection: {
title: 'Team Collaboration',
description: 'Work together with your team in real-time.',
features: [
'Invite unlimited team members',
'Role-based access control',
'Real-time collaboration',
],
icon: 'users',
order: 10,
}
Modules can indicate they provide public site pages:
providesSitePages: true
This is used by modules like end-user-auth that provide login/signup pages.
coreModuleId) for the hero and primary CTA.landingSection for secondary sections, but must not assume it controls the full landing layout.When building pages for project subdomains (e.g., myproject.credsnet.com), you must handle the case where the project context is not available from middleware headers (cache miss scenario).
The middleware uses Vercel KV to cache project data for fast edge resolution. When a project is first accessed or the cache expires, the middleware sets x-project-cache-miss: true but cannot set project-specific headers like x-project-slug because the data isn't in cache.
Use the resolveProjectContext() utility function which handles both scenarios:
// apps/web/src/app/site/utils.ts
import { resolveProjectContext } from './utils';
export default async function MyProjectPage() {
// This handles both cache hit and miss
const projectContext = await resolveProjectContext();
if (!projectContext) {
notFound();
}
// Now you have: projectId, projectSlug, projectTenantId, projectName, projectStatus
const { projectSlug, projectTenantId } = projectContext;
// Fetch full project data
const project = await getProjectBySlugGlobal(projectSlug);
// ...
}
x-project-id, x-project-slug, etc.) from KV cachehost header and queries the databaseEvery page under /app/site/ must use resolveProjectContext() instead of directly reading headers:
// ❌ WRONG - Will fail on cache miss
const projectSlug = headersList.get('x-project-slug');
if (!projectSlug) notFound();
// ✅ CORRECT - Handles both cache hit and miss
const projectContext = await resolveProjectContext();
if (!projectContext) notFound();
// apps/web/src/app/site/utils.ts
export interface ResolvedProjectContext {
projectId: string;
projectSlug: string;
projectTenantId: string;
projectName: string;
projectStatus: string;
}
export async function resolveProjectContext(): Promise<ResolvedProjectContext | null> {
const headersList = await headers();
// Try middleware headers first
let projectId = headersList.get('x-project-id');
let projectSlug = headersList.get('x-project-slug');
// ... other headers
// If we have all data, return it
if (projectId && projectSlug && projectTenantId && projectName) {
return { projectId, projectSlug, projectTenantId, projectName, projectStatus };
}
// Handle cache miss - extract from host header
const host = headersList.get('host') || '';
const extractedSlug = extractSlugFromHost(host);
if (extractedSlug) {
const dbProject = await getProjectBySlugGlobal(extractedSlug);
if (dbProject) {
// Cache for future requests
cacheProject(dbProject).catch(() => {});
return {
projectId: dbProject.id,
projectSlug: dbProject.slug,
// ...
};
}
}
return null;
}
Every module must register itself at the end of its index.ts:
import { registerModule, type ModuleDefinition } from '@v2/modules';
export const myModule: ModuleDefinition = {
metadata: { /* ... */ },
// ... other properties
};
// Auto-register module
registerModule(myModule);
// Export for direct imports
export default myModule;
minimumRolepnpm lint:module-migrationsquery from @v2/database in app/module code (guarded by pnpm lint:no-raw-query)query into module feature codeminimumRole on module admin API routesAlways use the provided database utilities:
import { withTenantProjectQuery, withTenantQuery, getPrivilegedRuntimeDb } from '@v2/database';
// Preferred for project-scoped runtime queries
const results = await withTenantProjectQuery(tenantId, projectId, (sql) => sql`
SELECT * FROM my_table
WHERE project_id = ${projectId}
`);
// Tenant-only runtime queries (no project context)
const tenantRows = await withTenantQuery(tenantId, (sql) => sql`
SELECT * FROM tenant_settings
`);
// Privileged runtime operations (control plane only; avoid in feature handlers)
const privileged = getPrivilegedRuntimeDb();
| Item | Convention | Example |
|---|---|---|
| Module ID | lowercase, kebab-case | customer-portal |
| Table names | {module}_{entity} | crm_contacts |
| Permission IDs | {module}:{resource}:{action} | crm:contacts:read |
| Event names | {module}.{entity}.{action} | crm.contact.created |
| Migration names | {number}_{description} | 0001_create_contacts |
src/
├── index.ts # Module definition only
├── api/
│ ├── contacts.ts # Contact-related handlers
│ └── deals.ts # Deal-related handlers
├── pages/
│ ├── contacts-page.tsx
│ └── deals-page.tsx
├── components/
│ ├── contact-form.tsx
│ └── deal-card.tsx
└── lib/
├── queries.ts # Database queries
└── utils.ts # Utility functions
Events should be namespaced and descriptive:
eventsEmitted: [
'crm.contact.created',
'crm.contact.updated',
'crm.contact.deleted',
'crm.deal.won',
'crm.deal.lost',
]
Here's a complete example of a minimal module:
// modules/notes/src/index.ts
import { registerModule, type ModuleDefinition } from '@v2/modules';
import { NotesPage } from './pages/notes-page';
import { handleGetNotes, handleCreateNote, handleDeleteNote } from './api/notes';
export const notesModule: ModuleDefinition = {
metadata: {
id: 'notes',
name: 'Notes',
description: 'Simple note-taking for your workspace',
version: '1.0.0',
icon: 'note',
category: 'operations',
defaultEnabled: false,
features: [
'Create and organize notes',
'Rich text formatting',
'Search across all notes',
],
tags: ['notes', 'productivity', 'documentation'],
eventsEmitted: [
'notes.note.created',
'notes.note.updated',
'notes.note.deleted',
],
landingSection: {
title: 'Notes & Documentation',
description: 'Keep all your important information in one place.',
features: [
'Unlimited notes',
'Rich text editor',
'Full-text search',
],
icon: 'note',
order: 20,
},
requiredSiteCapabilities: [
'layout.publicHeader',
'layout.workspaceContent',
'ui.navigation.moduleLinks',
'ui.surface.card',
'ui.form.controls',
'ui.feedback.error',
'ui.data.list',
'ui.data.keyValue',
],
},
// Backoffice (project-owner) layer
projectAdminNavigation: [
{
label: 'Notes',
href: '/',
icon: 'note',
},
],
projectAdminPages: {
'/': NotesPage,
},
projectAdminApiRoutes: [
{ method: 'GET', path: '/notes', handler: handleGetNotes, minimumRole: 'member' },
{ method: 'POST', path: '/notes', handler: handleCreateNote, minimumRole: 'admin' },
{ method: 'DELETE', path: '/notes/:id', handler: handleDeleteNote, minimumRole: 'admin' },
],
permissions: [
{
id: 'notes:notes:read',
name: 'View Notes',
description: 'Can view notes',
},
{
id: 'notes:notes:write',
name: 'Manage Notes',
description: 'Can create, edit, and delete notes',
},
],
migrations: [
{
name: '0001_create_notes_table',
up: `
CREATE TABLE IF NOT EXISTS notes_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notes_notes_tenant
ON notes_notes(tenant_id);
ALTER TABLE notes_notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes_notes FORCE ROW LEVEL SECURITY;
CREATE POLICY notes_notes_select ON notes_notes
FOR SELECT USING (tenant_id = current_tenant_id());
CREATE POLICY notes_notes_insert ON notes_notes
FOR INSERT WITH CHECK (tenant_id = current_tenant_id());
CREATE POLICY notes_notes_update ON notes_notes
FOR UPDATE USING (tenant_id = current_tenant_id());
CREATE POLICY notes_notes_delete ON notes_notes
FOR DELETE USING (tenant_id = current_tenant_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON notes_notes TO app_user;
`,
down: `DROP TABLE IF EXISTS notes_notes;`,
},
],
// Public (end-user) layer (optional)
sitePages: {
'/notes': NotesPublicPage,
},
siteNavigation: [{ label: 'Notes', href: '/notes', icon: 'note' }],
onEnable: async (tenantId: string) => {
console.log(`[Notes Module] Enabled for tenant ${tenantId}`);
},
onDisable: async (tenantId: string) => {
console.log(`[Notes Module] Disabled for tenant ${tenantId}`);
},
};
// Auto-register module
registerModule(notesModule);
export default notesModule;
| Version | Date | Changes |
|---|---|---|
| 1.0.0 | 2026-02-16 | Initial specification |
| 1.1.0 | 2026-02-18 | Document core-module experience + site dashboard sections + no-emoji icon guidance |
| 1.2.0 | 2026-02-21 | Add template capability contract, explicit API minimumRole RBAC requirements, and security lint gate rules |
For questions about module development, refer to:
/docs/modules@v2/modules