Skip to main content

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

  1. Versioning & Compatibility
  2. Import Surface
  3. Manifest Schema (v2)
  4. Contribution Types
  5. Lifecycle State Machine
  6. EventBus Integration
  7. ExtensionBaseService
  8. Data Collections & Uninstall Policies
  9. Migration System
  10. Dependency Management
  11. Health Monitoring & Auto-Disable
  12. Registry & Resolution Flow
  13. ESLint Boundary Enforcement
  14. 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 SyntaxMeaningExample
1.0.0Exact match onlyMust be exactly 1.0.0
^1.0.0Same major, minor/patch >= required1.0.0, 1.2.3 OK; 2.0.0 fails
~1.0.0Same major.minor, patch >= required1.0.0, 1.0.5 OK; 1.1.0 fails
If the compatibility check fails, activation is rejected with an error.

Manifest Version Field

manifestVersion?: 1 | 2;
ValueMeaning
Omitted / 1Legacy format (implicit). Backward-compatible parsing.
2Current 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:
@/shared/extension-api
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

ExportKindPurpose
CORE_API_VERSIONconst stringCurrent core API version
isCompatiblefunctionCheck semver range against core version
parseSemverfunctionParse version string into {major, minor, patch}
ExtensionBaseServiceabstract classBase class for extension service layer
createExtensionEventBusfunctionCreate 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

Widget & Command Registration Helpers

ExportPurpose
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

FieldTypeDescription
idstringUnique kebab-case identifier (e.g. 'grant-calendar')
namestringHuman-readable display name
descriptionstringShort description for marketplace cards
iconstringLucide icon name
categoryModuleCategoryMarketplace category enum
versionstringSemver version string
featuresFeature[]Feature enum values this module gates
permissionsstring[]Permission strings this module uses
basePathstringBase route path (e.g. '/impact')
layoutComponentstringPath to layout component relative to module root
routesModuleRouteEntry[]Route definitions

Optional Fields

FieldTypeDefaultDescription
manifestVersion1 | 21Schema version for forward-compatible parsing
minimumTierSubscriptionTierAnyLowest tier that can install
includedInTiersSubscriptionTier[][]Tiers where module is auto-available
requiredModulesstring[][]Extension IDs that must be active first
authorstringExtension author name
compatibility{ coreApiVersion: string }Core API semver range
codeDirectorystringSame as idDirectory name under src/extensions/
sidebarEntryModuleSidebarEntrySidebar navigation item
pricingModulePricingBilling configuration
dataCollectionsModuleDataCollection[][]Firestore collections owned by this module
contributionsExtensionContributionsWidgets, commands, events, settings, agent tools
lifecycleExtensionLifecycleHooksActivation/deactivation/tenant-switch hooks
migrationsExtensionMigration[][]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
}

Sidebar Entry

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.

4.1 Widgets (ExtensionWidgetContribution)

Dashboard widgets rendered in the widget grid.
FieldTypeRequiredDescription
idstringYesUnique within extension (qualified to ext_{extensionId}_{id})
titlestringYesDisplay title
descriptionstringYesWidget description
categorystringYesGrouping category
componentPathstringYesComponent path relative to extension root
iconstringYesLucide icon name
size'compact' | 'standard' | 'chart' | 'large'YesGrid size
requiredPermissionsstring[]NoPermissions 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.
FieldTypeRequiredDescription
idstringYesUnique within extension (qualified at runtime)
labelstringYesDisplay label in palette
descriptionstringNoDescription shown below label
iconstringYesLucide icon name (resolved to component at registration)
group'navigation' | 'create' | 'context' | 'ai'YesCommand group
actionstringYesNavigation path or callback identifier
keywordsstring[]NoExtra search keywords for fuzzy matching
permissionstringNoRequired permission (Permission enum name)
routeContextstringNoRoute context for relevance boosting
shortcutstringNoDisplay keyboard shortcut hint
prioritynumberNoSort priority within group (lower = higher)
sectionstringNoVisual 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.
FieldTypeRequiredDescription
eventTypesstring[]YesSystemEventType values to subscribe to
handlerPathstringYesModule path relative to extension root
descriptionstringNoHuman-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.
FieldTypeRequiredDescription
idstringYesUnique panel ID
labelstringYesDisplay label in Settings sidebar
iconstringYesLucide icon name
componentPathstringYesComponent path relative to extension root
sortOrdernumberNoPosition 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.

4.5 Agent Tools (ExtensionAgentToolContribution)

AI agent tools available when the extension is active.
FieldTypeRequiredDescription
namestringYesTool name (unique across all extensions)
descriptionstringYesTool description for AI agent context
requiredPermissionsstring[]YesPermissions required to invoke
handlerPathstringYesHandler module path relative to extension root
creditCostnumberYesCredit 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

StateEnumDescription
Available(no document)Not installed; visible in marketplace
TrialingModuleInstallStatus.TRIALINGInstalled with free trial; trialEndsAt set
ActiveModuleInstallStatus.ACTIVEFully active; contributions registered
InactiveModuleInstallStatus.INACTIVEInstalled but deactivated; contributions unregistered
SuspendedModuleInstallStatus.SUSPENDEDForcibly disabled (payment failure, health auto-disable, admin action)
ExpiredModuleInstallStatus.EXPIREDTrial expired without conversion

Firestore Path

organizations/{orgId}/moduleInstallations/{moduleId}

State Transitions

TransitionMethodPre-conditionsSide Effects
Available → Trialing/Activeinstall()Dependencies active, tier eligible, compatibleCreates installation doc, runs migrations, activates contributions, emits MODULE_INSTALLED
Trialing → Activeactivate()Runs migrations, activates contributions, emits MODULE_ACTIVATED
Active → Inactivedeactivate()No dependents activeDeactivates contributions, cleans EventBus subscriptions, emits MODULE_DEACTIVATED
Active → Suspendedsuspend(reason)Deactivates contributions, emits MODULE_SUSPENDED
Suspended → Activereactivate()Runs migrations, activates contributions, emits MODULE_ACTIVATED
Trialing → ExpiredexpireTrial()Deactivates contributions, emits MODULE_TRIAL_EXPIRED
Any installed → Availableuninstall()No dependents installedDeactivates if active, applies data uninstall policies, deletes installation doc, emits MODULE_UNINSTALLED
Active → SuspendedhandlePaymentFailed()Suspends with reason 'payment_failed'

Activation Flow (Detail)

  1. Validate required dependencies are installed and active
  2. Check API version compatibility via isCompatible()
  3. Run pending data migrations via ExtensionMigrationService
  4. Register widget contributions (Firestore persistence)
  5. Register command contributions (in-memory ActionRegistry)
  6. Subscribe event handler declarations (scoped EventBus)
  7. Register agent tool contributions (AgentToolRegistry)
  8. Track settings panel contributions (in-memory)
  9. Call lifecycle.onActivate hook (if declared)

Deactivation Flow (Detail)

  1. Call lifecycle.onDeactivate hook (if declared)
  2. Unregister agent tools
  3. Unsubscribe all extension event handlers via EventBus.unsubscribeExtension(extensionId)
  4. Unregister command contributions
  5. Unregister widget contributions (Firestore cleanup)
  6. 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:
MethodBehavior
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

MemberTypePurpose
extensionIdstringExtension identifier for audit logs and event scoping
collectionNamestringFirestore collection name (inherited from BaseService)
serviceNamestringService name for logging (inherited from BaseService)

Behavioral Differences from BaseService

BehaviorBaseServiceExtensionBaseService
Audit logsStandard metadataAuto-includes extensionId in metadata
Organization scopingOptionalEnforcedlogSuccess validates organizationId is present
Event subscriptionsDirect eventBus.on()subscribeToEvent() delegates to subscribeForExtension()
Constructor DILogger, EventBus, TimeProvider, FirestoreClientSame — 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

PolicyBehavior on UninstallOn Reinstall
retain (default)Data kept as-isReinstalling restores access
archiveDocuments soft-deleted (isArchived = true)Reinstalling can restore
deleteDocuments permanently removedData 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

  1. ExtensionMigrationService.getPendingMigrations() compares installation.installedVersion against manifest.version
  2. Pending migrations are sorted by fromVersion and executed in order
  3. Each migration module is dynamically imported from src/extensions/{codeDirectory}/{migrationPath}
  4. 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

OperationValidation
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.
ParameterDefaultDescription
Error threshold3Number of errors before auto-disable
Time window5 minutesWindow for counting errors

Behavior

  1. Extension route groups are wrapped in <ErrorBoundary>
  2. Crashes are reported to ExtensionHealthService
  3. If threshold exceeded within window: extension is suspended via ExtensionInstallationService.suspend()
  4. 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 & Event Handler Isolation

  • 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.
MethodReturnsDescription
init()voidLoad all manifests (async, called once at boot)
seed(manifests)voidBulk-load manifests (for testing)
reset()voidClear all cached manifests
getManifest(id)ModuleManifest | undefinedLook 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.LazyExoticComponentCached 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

LayerCan ImportCannot 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 hooksOwn services, @/shared/extension-api typesfirebase/*, @/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 TypeVersion BumpExample
New optional manifest fieldMinorAdding manifest.analytics
New contribution typeMinorAdding contributions.dashboardCards
New export from extension-apiMinorExporting a new helper function
Bug fix in registration logicPatchFixing widget ID dedup
Removing/renaming an exportMajorRemoving parseSemver
Changing activation flow orderMajorRegistering commands before widgets

Migration Path

When a major version bump is necessary:
  1. Bump CORE_API_VERSION to the new major
  2. Update all built-in extension manifests to declare the new coreApiVersion range
  3. Extensions with older compatibility.coreApiVersion will fail activation with a clear error message
  4. Document migration steps in release notes

Appendix: File Map

FilePurpose
src/shared/extension-api/index.tsStable public API facade
src/shared/extension-api/version.tsCore API version + compatibility checking
src/shared/extension-api/ExtensionBaseService.tsBase service class for extensions
src/shared/extension-api/extensionEventBus.tsScoped EventBus factory
src/shared/extension-api/extensionWidgets.tsWidget registration helpers
src/shared/extension-api/extensionCommands.tsCommand registration helpers
src/shared/extension-api/types.tsCurated type re-exports
src/shared/platform/ExtensionRegistry.tsStatic manifest catalog
src/shared/platform/ExtensionInstallationService.tsLifecycle state machine
src/shared/platform/ExtensionActivationService.tsContribution registration orchestrator
src/shared/platform/ExtensionMigrationService.tsData migration runner
src/shared/platform/ExtensionHealthService.tsHealth monitoring + auto-disable
src/shared/platform/widgetRegistry.tsFirestore-backed widget management
src/features/command-palette/registry.tsIn-memory command action registry
src/features/extensions/contracts.tsExtension contract definitions
@grantmaster/shared/eventsEventPayloadMap + SystemEventType enum
.eslint/rules/no-eventbus-in-components.jsESLint boundary rule
.eslint/rules/no-direct-firestore-import.jsESLint boundary rule