Documentation Index
Fetch the complete documentation index at: https://grantmaster.dev/llms.txt
Use this file to discover all available pages before exploring further.
Extension API Contract
Manifest Version: 2
Core API Version: 1.0.0
Last updated: 2026-02-21
This document is the formal specification of the contract between GrantMaster extensions and the platform. It covers the stable API surface, versioning guarantees, manifest schema, contribution registration, lifecycle management, and data ownership rules.
For a hands-on development guide with code examples, see Extension Development Guide.
Table of Contents
- Versioning & Compatibility
- Import Surface
- Manifest Schema (v2)
- Contribution Types
- Lifecycle State Machine
- EventBus Integration
- ExtensionBaseService
- Data Collections & Uninstall Policies
- Migration System
- Dependency Management
- Health Monitoring & Auto-Disable
- Registry & Resolution Flow
- ESLint Boundary Enforcement
- Breaking Change Policy
1. Versioning & Compatibility
Core API Version
The platform exposes a semver version constant that extensions can depend on:
CORE_API_VERSION = '1.0.0'
Extensions declare a required version range in their manifest:
compatibility: {
coreApiVersion: '^1.0.0',
}
Compatibility Checking
At activation time, isCompatible(requiredRange) is called. The function supports:
| Range Syntax | Meaning | Example |
|---|
1.0.0 | Exact match only | Must be exactly 1.0.0 |
^1.0.0 | Same major, minor/patch >= required | 1.0.0, 1.2.3 OK; 2.0.0 fails |
~1.0.0 | Same major.minor, patch >= required | 1.0.0, 1.0.5 OK; 1.1.0 fails |
If the compatibility check fails, activation is rejected with an error.
Manifest Version Field
| Value | Meaning |
|---|
Omitted / 1 | Legacy format (implicit). Backward-compatible parsing. |
2 | Current format. Supports typed contributions, lifecycle hooks, migrations, agent tools. |
All built-in extensions ship with manifestVersion: 2.
2. Import Surface
Extensions must import from the stable API facade:
Direct imports from unrelated internal modules are discouraged and may trigger ESLint warnings. Prefer the owning feature contract or the extension API facade.
Exported Values
| Export | Kind | Purpose |
|---|
CORE_API_VERSION | const string | Current core API version |
isCompatible | function | Check semver range against core version |
parseSemver | function | Parse version string into {major, minor, patch} |
ExtensionBaseService | abstract class | Base class for extension service layer |
createExtensionEventBus | function | Create scoped EventBus wrapper |
Exported Enums
Permission, SystemRole, SystemEventType, EventSeverity, ModuleStatus, ModuleInstallStatus, ModulePricingModel, ModuleCategory, WidgetCategory, WidgetSize, WidgetHeight
Exported Types
SystemEvent, EventBusStats, EventHandler, IEventBus, ILogger, ITimeProvider, IFirestoreClient, LogContext, ModuleManifest, ModuleDefinition, ModuleInstallation, ModuleRouteEntry, ModuleSidebarEntry, ModuleDataCollection, ModulePricing, ResolvedModule, ExtensionContributions, ExtensionEventHandlerDeclaration, ExtensionSettingsPanelContribution, ExtensionAgentToolContribution, ExtensionLifecycleHooks, ExtensionMigration, WidgetDefinition, CommandAction, CommandGroup, ServiceResult, AgentOperationContext, ServiceOperationOptions, Feature
| Export | Purpose |
|---|
registerExtensionWidgets(extensionId, widgets) | Persist widget definitions to WidgetRegistry |
unregisterExtensionWidgets(extensionId) | Remove widget definitions and assignments |
getExtensionWidgetIds(extensionId) | List widget IDs for an extension |
getExtensionWidgetCount(extensionId) | Count of registered widgets |
clearAllExtensionWidgets() | Remove all extension widgets (admin/test) |
registerExtensionActions(extensionId, commands) | Register commands in ActionRegistry |
unregisterExtensionActions(extensionId) | Remove all extension commands |
getExtensionActionIds(extensionId) | List command IDs for an extension |
getExtensionActionCount(extensionId) | Count of registered commands |
clearAllExtensionActions() | Remove all extension commands (admin/test) |
3. Manifest Schema (v2)
The ModuleManifest interface is the single source of truth for an extension’s metadata, routing, and contribution declarations.
Required Fields
| Field | Type | Description |
|---|
id | string | Unique kebab-case identifier (e.g. 'grant-calendar') |
name | string | Human-readable display name |
description | string | Short description for marketplace cards |
icon | string | Lucide icon name |
category | ModuleCategory | Marketplace category enum |
version | string | Semver version string |
features | Feature[] | Feature enum values this module gates |
permissions | string[] | Permission strings this module uses |
basePath | string | Base route path (e.g. '/impact') |
layoutComponent | string | Path to layout component relative to module root |
routes | ModuleRouteEntry[] | Route definitions |
Optional Fields
| Field | Type | Default | Description |
|---|
manifestVersion | 1 | 2 | 1 | Schema version for forward-compatible parsing |
minimumTier | SubscriptionTier | Any | Lowest tier that can install |
includedInTiers | SubscriptionTier[] | [] | Tiers where module is auto-available |
requiredModules | string[] | [] | Extension IDs that must be active first |
author | string | — | Extension author name |
compatibility | { coreApiVersion: string } | — | Core API semver range |
codeDirectory | string | Same as id | Directory name under src/extensions/ |
sidebarEntry | ModuleSidebarEntry | — | Sidebar navigation item |
pricing | ModulePricing | — | Billing configuration |
dataCollections | ModuleDataCollection[] | [] | Firestore collections owned by this module |
contributions | ExtensionContributions | — | Widgets, commands, events, settings, agent tools |
lifecycle | ExtensionLifecycleHooks | — | Activation/deactivation/tenant-switch hooks |
migrations | ExtensionMigration[] | [] | Ordered data migration declarations |
Route Entry
interface ModuleRouteEntry {
path: string; // Relative to basePath (e.g. 'dashboard', 'indicators/:id')
component?: string; // Lazy import path relative to module root
redirect?: string; // Redirect target instead of rendering
}
interface ModuleSidebarEntry {
label: string; // Display text
icon: string; // Lucide icon name
to: string; // Absolute path (e.g. '/impact')
permission: string; // Required permission to see item
position: 'main' | 'bottom'; // Nav section
sortOrder: number; // Lower = higher in list
}
4. Contribution Types
Extensions declare contributions in manifest.contributions. All contributions are automatically registered on activation and unregistered on deactivation by ExtensionActivationService.
Dashboard widgets rendered in the widget grid.
| Field | Type | Required | Description |
|---|
id | string | Yes | Unique within extension (qualified to ext_{extensionId}_{id}) |
title | string | Yes | Display title |
description | string | Yes | Widget description |
category | string | Yes | Grouping category |
componentPath | string | Yes | Component path relative to extension root |
icon | string | Yes | Lucide icon name |
size | 'compact' | 'standard' | 'chart' | 'large' | Yes | Grid size |
requiredPermissions | string[] | No | Permissions required to view |
Registration: WidgetRegistryService persists widget definitions to Firestore (platform/widgets/definitions). Assignments and per-org overrides are stored separately.
ID Qualification: Widget IDs are prefixed at registration: ext_{extensionId}_{id} to prevent collisions across extensions.
4.2 Commands (ExtensionCommandContribution)
Command palette actions accessible via Cmd+K.
| Field | Type | Required | Description |
|---|
id | string | Yes | Unique within extension (qualified at runtime) |
label | string | Yes | Display label in palette |
description | string | No | Description shown below label |
icon | string | Yes | Lucide icon name (resolved to component at registration) |
group | 'navigation' | 'create' | 'context' | 'ai' | Yes | Command group |
action | string | Yes | Navigation path or callback identifier |
keywords | string[] | No | Extra search keywords for fuzzy matching |
permission | string | No | Required permission (Permission enum name) |
routeContext | string | No | Route context for relevance boosting |
shortcut | string | No | Display keyboard shortcut hint |
priority | number | No | Sort priority within group (lower = higher) |
section | string | No | Visual section label in palette |
Registration: ActionRegistry singleton (in-memory). Commands are prefixed ext_{extensionId}_{id}.
Search scoring: Exact match (100) > Label starts-with (80) > Label contains (60) > Keywords (40) > Description (30).
4.3 Event Handlers (ExtensionEventHandlerDeclaration)
Declare event subscriptions managed by the platform.
| Field | Type | Required | Description |
|---|
eventTypes | string[] | Yes | SystemEventType values to subscribe to |
handlerPath | string | Yes | Module path relative to extension root |
description | string | No | Human-readable description for admin UI |
Resolution: At activation, the handler module is dynamically imported from src/extensions/{codeDirectory}/{handlerPath}. The module must export a default async function.
Subscription tracking: Subscriptions are registered via EventBus.subscribeForExtension() and automatically cleaned up on deactivation via EventBus.unsubscribeExtension().
4.4 Settings Panels (ExtensionSettingsPanelContribution)
Panels rendered in the Settings page.
| Field | Type | Required | Description |
|---|
id | string | Yes | Unique panel ID |
label | string | Yes | Display label in Settings sidebar |
icon | string | Yes | Lucide icon name |
componentPath | string | Yes | Component path relative to extension root |
sortOrder | number | No | Position in settings sidebar (lower = higher) |
Registration: Settings panels are tracked in-memory by ExtensionActivationService. The Settings page reads active contributions from ExtensionContext at render time.
AI agent tools available when the extension is active.
| Field | Type | Required | Description |
|---|
name | string | Yes | Tool name (unique across all extensions) |
description | string | Yes | Tool description for AI agent context |
requiredPermissions | string[] | Yes | Permissions required to invoke |
handlerPath | string | Yes | Handler module path relative to extension root |
creditCost | number | Yes | Credit cost per invocation |
Registration: AgentToolRegistry. Tools are available to the AI agent when the extension is active and the user has required permissions.
5. Lifecycle State Machine
State Definitions
| State | Enum | Description |
|---|
| Available | (no document) | Not installed; visible in marketplace |
| Trialing | ModuleInstallStatus.TRIALING | Installed with free trial; trialEndsAt set |
| Active | ModuleInstallStatus.ACTIVE | Fully active; contributions registered |
| Inactive | ModuleInstallStatus.INACTIVE | Installed but deactivated; contributions unregistered |
| Suspended | ModuleInstallStatus.SUSPENDED | Forcibly disabled (payment failure, health auto-disable, admin action) |
| Expired | ModuleInstallStatus.EXPIRED | Trial expired without conversion |
Firestore Path
organizations/{orgId}/moduleInstallations/{moduleId}
State Transitions
| Transition | Method | Pre-conditions | Side Effects |
|---|
| Available → Trialing/Active | install() | Dependencies active, tier eligible, compatible | Creates installation doc, runs migrations, activates contributions, emits MODULE_INSTALLED |
| Trialing → Active | activate() | — | Runs migrations, activates contributions, emits MODULE_ACTIVATED |
| Active → Inactive | deactivate() | No dependents active | Deactivates contributions, cleans EventBus subscriptions, emits MODULE_DEACTIVATED |
| Active → Suspended | suspend(reason) | — | Deactivates contributions, emits MODULE_SUSPENDED |
| Suspended → Active | reactivate() | — | Runs migrations, activates contributions, emits MODULE_ACTIVATED |
| Trialing → Expired | expireTrial() | — | Deactivates contributions, emits MODULE_TRIAL_EXPIRED |
| Any installed → Available | uninstall() | No dependents installed | Deactivates if active, applies data uninstall policies, deletes installation doc, emits MODULE_UNINSTALLED |
| Active → Suspended | handlePaymentFailed() | — | Suspends with reason 'payment_failed' |
Activation Flow (Detail)
- Validate required dependencies are installed and active
- Check API version compatibility via
isCompatible()
- Run pending data migrations via
ExtensionMigrationService
- Register widget contributions (Firestore persistence)
- Register command contributions (in-memory ActionRegistry)
- Subscribe event handler declarations (scoped EventBus)
- Register agent tool contributions (AgentToolRegistry)
- Track settings panel contributions (in-memory)
- Call
lifecycle.onActivate hook (if declared)
Deactivation Flow (Detail)
- Call
lifecycle.onDeactivate hook (if declared)
- Unregister agent tools
- Unsubscribe all extension event handlers via
EventBus.unsubscribeExtension(extensionId)
- Unregister command contributions
- Unregister widget contributions (Firestore cleanup)
- Clear tracked settings panels
6. EventBus Integration
Scoped Event Bus
Extensions must use createExtensionEventBus() rather than the raw eventBus singleton:
const bus = createExtensionEventBus(extensionId, optionalBusInstance);
The scoped bus provides:
| Method | Behavior |
|---|
emit(event) | Enriches event.metadata.source with extension:{extensionId}, then delegates to global bus |
on(eventType, handler) | Delegates to bus.subscribeForExtension(extensionId, eventType, handler) for lifecycle tracking |
getSubscriptionCount() | Returns count of active subscriptions for this extension |
Subscription Lifecycle
- Subscriptions registered via
subscribeForExtension() are tracked per extension ID
- On deactivation,
unsubscribeExtension(extensionId) bulk-removes all subscriptions
- Each subscription also returns an individual unsubscribe function for manual cleanup
getExtensionSubscriptionCount(extensionId) reports active subscription count
Type-Safe Subscriptions
The EventBus supports type-safe subscriptions via EventPayloadMap:
// Single event type — payload is inferred from EventPayloadMap
bus.on(SystemEventType.EXPENSE_APPROVED, (event) => {
// event.payload is typed as ExpenseApprovedPayload
});
// Multiple event types — generic payload
bus.on([SystemEventType.EXPENSE_APPROVED, SystemEventType.EXPENSE_REJECTED], (event) => {
// event.payload is unknown — narrow manually
});
Event Emission Contract
All emitted events must include:
{
type: SystemEventType; // Event type enum value
organizationId: string; // Tenant scope
userId: string; // Acting user
severity: EventSeverity; // INFO, WARNING, ERROR, CRITICAL
timestamp: Date; // When the event occurred
payload: T; // Event-specific payload
metadata?: { // Optional metadata
source?: string; // Auto-set by scoped bus: 'extension:{id}'
[key: string]: unknown;
};
}
7. ExtensionBaseService
Extensions should extend ExtensionBaseService<T> instead of BaseService<T> directly.
Abstract Members
| Member | Type | Purpose |
|---|
extensionId | string | Extension identifier for audit logs and event scoping |
collectionName | string | Firestore collection name (inherited from BaseService) |
serviceName | string | Service name for logging (inherited from BaseService) |
Behavioral Differences from BaseService
| Behavior | BaseService | ExtensionBaseService |
|---|
| Audit logs | Standard metadata | Auto-includes extensionId in metadata |
| Organization scoping | Optional | Enforced — logSuccess validates organizationId is present |
| Event subscriptions | Direct eventBus.on() | subscribeToEvent() delegates to subscribeForExtension() |
| Constructor DI | Logger, EventBus, TimeProvider, FirestoreClient | Same — defaults to production singletons |
Inherited Capabilities
From BaseService: withErrorBoundary(), validateInput() (Zod), logSuccess() / logFailure() (audit), cache invalidation, quota enforcement, agent context propagation.
8. Data Collections & Uninstall Policies
Extensions declare their Firestore collections in manifest.dataCollections:
interface ModuleDataCollection {
name: string; // Firestore collection name
label: string; // Human-readable label
orgIdField?: string; // Tenant scope field (default: 'organizationId')
uninstallPolicy?: 'retain' | 'archive' | 'delete';
}
Uninstall Policies
| Policy | Behavior on Uninstall | On Reinstall |
|---|
retain (default) | Data kept as-is | Reinstalling restores access |
archive | Documents soft-deleted (isArchived = true) | Reinstalling can restore |
delete | Documents permanently removed | Data is gone |
The ExtensionInstallationService.uninstall() method iterates declared collections and applies the appropriate policy, scoped by organizationId.
9. Migration System
Declaration
migrations: [
{
fromVersion: '1.0.0',
toVersion: '1.1.0',
description: 'Add priority field to calendar events',
migrationPath: 'migrations/v1_1_addPriority',
},
]
Execution
ExtensionMigrationService.getPendingMigrations() compares installation.installedVersion against manifest.version
- Pending migrations are sorted by
fromVersion and executed in order
- Each migration module is dynamically imported from
src/extensions/{codeDirectory}/{migrationPath}
- The module must export a default async function receiving
MigrationContext:
interface MigrationContext {
extensionId: string;
organizationId: string;
fromVersion: string;
toVersion: string;
}
Failure Handling
If any migration throws, the extension is suspended with reason 'migration_failed'. The admin must investigate and resolve the issue before the extension can be reactivated.
Timing
Migrations run during:
- Initial
install() (if installedVersion < manifest.version)
activate() / reactivate() (if an upgrade occurred while inactive)
10. Dependency Management
Declaration
requiredModules: ['impact', 'compliance-vault'],
Validation Points
| Operation | Validation |
|---|
install() | All requiredModules must be installed and active |
deactivate() | Cannot deactivate if other active extensions list this module in requiredModules |
uninstall() | Cannot uninstall if other installed extensions list this module in requiredModules |
Dependency Resolution
getDependentExtensions(moduleId) returns a list of installed extensions that depend on the given module. This is used to show dependency warnings in the admin UI before deactivation/uninstall.
11. Health Monitoring & Auto-Disable
ExtensionHealthService
Monitors extension runtime health and auto-disables crash-prone extensions.
| Parameter | Default | Description |
|---|
| Error threshold | 3 | Number of errors before auto-disable |
| Time window | 5 minutes | Window for counting errors |
Behavior
- Extension route groups are wrapped in
<ErrorBoundary>
- Crashes are reported to
ExtensionHealthService
- If threshold exceeded within window: extension is suspended via
ExtensionInstallationService.suspend()
- Events emitted:
EXTENSION_AUTO_DISABLED, EXTENSION_HEALTH_CHECK_FAILED
Health Record
interface ExtensionHealthRecord {
extensionId: string;
errorTimestamps: Date[];
totalErrorCount: number;
lastError?: { message: string; timestamp: Date };
}
- Widget crashes show a placeholder, not a broken dashboard
- Event handler failures are tracked — repeated failures auto-unsubscribe the handler
- Agent tool failures are reported and credit costs may be refunded
12. Registry & Resolution Flow
ModuleRegistry (Compile-Time Catalog)
ExtensionRegistry.ts maintains a static catalog of all built-in extension manifests via lazy loaders.
| Method | Returns | Description |
|---|
init() | void | Load all manifests (async, called once at boot) |
seed(manifests) | void | Bulk-load manifests (for testing) |
reset() | void | Clear all cached manifests |
getManifest(id) | ModuleManifest | undefined | Look up single manifest |
getAllManifests() | ModuleManifest[] | All registered manifests |
getActiveManifests(activeIds) | ModuleManifest[] | Filter to active-only |
getActiveSidebarEntries(activeIds) | ModuleSidebarEntry[] | Sidebar items for active modules |
getLazyComponent(id, path) | React.LazyExoticComponent | Cached React.lazy() wrapper |
resolveModules(installations) | ResolvedModule[] | Merge manifests with installation state |
Resolution Flow
ModuleRegistry.getAllManifests() → Static catalog (12 built-in extensions)
+
Firestore: org/{id}/moduleInstallations → Per-org installation state
↓
ModuleRegistry.resolveModules() → ResolvedModule[] (manifest + installation + flags)
↓
ExtensionContext (React) → isActive, isTrialing, isSuspended per module
ResolvedModule
interface ResolvedModule {
manifest: ModuleManifest;
installation: ModuleInstallation | null;
isActive: boolean;
isTrialing: boolean;
isSuspended: boolean;
LayoutComponent?: ComponentType<{ children?: React.ReactNode }>;
}
13. ESLint Boundary Enforcement
custom/no-eventbus-in-components (warn)
Prevents direct @/core/eventBus imports in component and page files. EventBus usage belongs in services and infrastructure code.
Allowed paths: /services/, /core/, /extensions/, /infrastructure/, /contexts/, test files
Blocked paths: /components/, /pages/
custom/no-direct-firestore-import (warn)
Prevents direct firebase/firestore imports outside the service layer. Components, hooks, and contexts must go through services.
Allowed paths: /services/, /utils/, test files
Blocked paths: /components/, /hooks/, /contexts/, /pages/
Import Guidance
| Layer | Can Import | Cannot Import |
|---|
| Extension components | @/shared/extension-api, own services/hooks | @/core/*, firebase/*, @/core/eventBus |
| Extension services | @/shared/extension-api, ExtensionBaseService | @/core/eventBus directly (use scoped bus) |
| Extension hooks | Own services, @/shared/extension-api types | firebase/*, @/core/dataService |
14. Breaking Change Policy
What Constitutes a Breaking Change
- Removing or renaming an export from
@/shared/extension-api
- Changing the signature of
ExtensionBaseService methods
- Removing fields from
ModuleManifest that extensions depend on
- Changing the
SystemEvent envelope structure
- Altering contribution registration behavior (e.g. ID qualification scheme)
- Modifying lifecycle hook calling conventions
Semver Guarantees
| Change Type | Version Bump | Example |
|---|
| New optional manifest field | Minor | Adding manifest.analytics |
| New contribution type | Minor | Adding contributions.dashboardCards |
| New export from extension-api | Minor | Exporting a new helper function |
| Bug fix in registration logic | Patch | Fixing widget ID dedup |
| Removing/renaming an export | Major | Removing parseSemver |
| Changing activation flow order | Major | Registering commands before widgets |
Migration Path
When a major version bump is necessary:
- Bump
CORE_API_VERSION to the new major
- Update all built-in extension manifests to declare the new
coreApiVersion range
- Extensions with older
compatibility.coreApiVersion will fail activation with a clear error message
- Document migration steps in release notes
Appendix: File Map
| File | Purpose |
|---|
src/shared/extension-api/index.ts | Stable public API facade |
src/shared/extension-api/version.ts | Core API version + compatibility checking |
src/shared/extension-api/ExtensionBaseService.ts | Base service class for extensions |
src/shared/extension-api/extensionEventBus.ts | Scoped EventBus factory |
src/shared/extension-api/extensionWidgets.ts | Widget registration helpers |
src/shared/extension-api/extensionCommands.ts | Command registration helpers |
src/shared/extension-api/types.ts | Curated type re-exports |
src/shared/platform/ExtensionRegistry.ts | Static manifest catalog |
src/shared/platform/ExtensionInstallationService.ts | Lifecycle state machine |
src/shared/platform/ExtensionActivationService.ts | Contribution registration orchestrator |
src/shared/platform/ExtensionMigrationService.ts | Data migration runner |
src/shared/platform/ExtensionHealthService.ts | Health monitoring + auto-disable |
src/shared/platform/widgetRegistry.ts | Firestore-backed widget management |
src/features/command-palette/registry.ts | In-memory command action registry |
src/features/extensions/contracts.ts | Extension contract definitions |
@grantmaster/shared/events | EventPayloadMap + SystemEventType enum |
.eslint/rules/no-eventbus-in-components.js | ESLint boundary rule |
.eslint/rules/no-direct-firestore-import.js | ESLint boundary rule |