This document covers the pluggable registry under src/modules/registry.
The current source layout and proposed boundaries are inventoried in
docs/source-architecture.md. src/modules is a runtime plugin registry,
not a second home for application feature modules.
The module registry installs pluggable packages without adding them to the core application module graph. It validates manifests, checks compatibility with the running API version, stores one current version per module name, and isolates enablement and configuration by tenant.
Create a module.manifest.json next to the module entry point:
{
"name": "portfolio-exporter",
"version": "1.0.0",
"core": ">=0.1.0 <1.0.0",
"hooks": {
"onInstall": true,
"onUpgrade": true,
"onUninstall": false
},
"entryPoint": "@organization/portfolio-exporter"
}The schema requires:
name: lowercase module identifier.version: valid semantic version.core: semantic-version range supported by the module.hooks: booleans declaringonInstall,onUpgrade, andonUninstall.entryPoint: installed package reference or a runtime JavaScript entry point inside the rootmodules/directory. Local entry points outside that directory are rejected so manifests cannot load core source files.
The machine-readable schema is in
src/modules/registry/module-manifest.schema.json. Runtime validation uses the
equivalent class-validator DTO plus semver, so invalid versions and ranges are
rejected even when a client does not use the JSON Schema.
Local modules must expose JavaScript that Node can load without ts-node.
TypeScript authors should compile their package before registration and ship
declarations that implement ModuleLifecycle. Published npm package entry points
are resolved normally from node_modules.
The entry point must default-export a lifecycle class or object implementing:
interface ModuleLifecycle {
onInstall?(): Promise<void>;
onUpgrade?(fromVersion: string, toVersion: string): Promise<void>;
onUninstall?(): Promise<void>;
}A hook marked true must be implemented. Hooks execute inside the registry's
database transaction where possible. A thrown error rolls back the registry
version and status. Because external services cannot participate in the database
transaction, hooks must be idempotent and compensate for external side effects.
Registry endpoints require an authenticated administrator. Registry management bypasses the general user KYC guard because it is an administrative control-plane operation; the global authentication and role guards still apply.
POST the manifest and its metadata to POST /api/v1/modules:
{
"manifest": {
"name": "portfolio-exporter",
"version": "1.0.0",
"core": ">=0.1.0 <1.0.0",
"hooks": {
"onInstall": true,
"onUpgrade": true,
"onUninstall": false
},
"entryPoint": "@organization/portfolio-exporter"
},
"description": "Exports tenant portfolios",
"author": "Organization"
}The API reads the core version from the root package.json. Registration and
enablement fail with a message containing the required range and actual version
when the module is incompatible.
Posting a new name runs onInstall. Posting the same name with a strictly newer
version runs onUpgrade and updates the existing registry row. Equal versions and
downgrades are rejected.
Enable a module with POST /api/v1/modules/:id/enable and disable it with
POST /api/v1/modules/:id/disable:
{
"tenantId": "tenant-123",
"config": { "format": "csv" }
}Each operation changes only that tenant's TenantModuleState. The project has no
canonical tenant entity, so tenantId is an opaque identifier supplied by the
caller. Omitting tenantId creates or updates the nullable global-default state;
it does not alter any explicit tenant row. An explicit tenant state therefore
remains isolated from the default.
Resolve the effective state for a tenant with:
GET /api/v1/modules/:id/state?tenantId=tenant-123
Resolution uses the explicit tenant row first, then the global-default row. When neither exists the result is an implicit disabled state. A disabled explicit row therefore overrides an enabled global default.
A module can be removed with DELETE /api/v1/modules/:id only when every tenant
and global-default state is disabled. Deregistration runs onUninstall when it is
declared.
The working example is in modules/example-grant-module. Its CommonJS entry
point is loadable by the development server, the bundled application, and the
production Docker image; index.d.ts declares the lifecycle TypeScript contract.
npm install
npm run migration:run
npm run start:dev
MODULE_REGISTRY_TOKEN=<admin-token> npm run module:example:registerMODULE_REGISTRY_TOKEN must contain an administrator bearer token. Set
MODULE_REGISTRY_URL to use a non-default API URL. Inspect the registered module
with GET /api/v1/modules, then use its returned UUID in the enable, disable, and
state-resolution endpoints.