Application registry
How applications self-describe through app.config.ts, register with RegistryService, and get mounted by the web app.
The application registry is how Africa OS knows which applications exist, what they look like, and which organizations can use them. Applications are not hardcoded into the platform - they self-describe through an app.config.ts, register their metadata, and the platform reads it back to build the launcher and mount modules.
The three-sided relationship
- Definition - what the application is: slug, name, icon, color, description, route, offline support.
- Enablement - whether a specific organization can use it, tracked in
organization_applications. - Mount - how the web app renders it, wired in the app registry (
app-registry.tsx).
The application definition
platform/organizations/src/metadata.ts defines the contract every app.config.ts must satisfy:
export const applicationDefinitionSchema = z.object({
slug: z.string().min(1),
name: z.string().min(1),
icon: z.string().optional(), // a lucide icon name
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), // #2563eb
description: z.string().optional(),
route: z.string().startsWith("/").optional(), // entry path, relative
supportsOffline: z.boolean().default(false),
});
When read for a specific organization, the definition is extended with enabled - an organization-scoped fact, never part of the definition itself.
Registering an application
Applications register through RegistryService.register, which validates the definition with the schema and upserts on slug:
pnpm db:register-apps
The script reads every application package’s app.config.ts, calls register for each, and stores the metadata in platform.applications. It is safe to run repeatedly - it refreshes metadata on conflict.
Reading the registry
RegistryService offers:
| Method | Purpose |
|---|---|
listForOrganization(organizationId) |
The enabled applications for an org, as full metadata. This drives the launcher. |
listAll() |
Every registered application, no org context. The platform/admin view. |
getForOrganization(organizationId, slug) |
One enabled application by slug, with ApplicationNotFound/ApplicationDisabled failures. Used by the request runtime. |
setEnabled(organizationId, slug, enabled) |
Toggle whether an org can use an application. |
register(definition) |
Upsert an application definition. |
Tenant scoping is the caller’s responsibility, with a hard rule: organizationId must come from the request runtime (CurrentOrganization), never from client input.
The availability check
When a request is scoped to :organization/:application/*, the request runtime calls getForOrganization. The check has three gates:
- The application exists in
platform.applications(ApplicationNotFound). - It is enabled for the organization in
organization_applications(ApplicationDisabled). - The user holds
platform.applications.access(Forbidden).
This is the platform-level availability gate, resolved before any domain permission.
Mounting applications
The web app keeps a lazy-mount map (apps/web/src/features/applications/app-registry.tsx) from application slug to component:
export const applicationMounts: Record<string, ComponentType<ApplicationMountProps>> = {
retail: lazy(() =>
import("@africaos/retail").then((module) => ({
default: module.RetailApplication,
}))
),
agriculture: lazy(() => ...),
// ...
};
Each mount is a React.lazy dynamic import, so every application is its own route-level chunk and the shell never pays for an application it does not open. Adding an application is one entry in this map plus its app.config.ts.
The launcher
The dashboard launcher is fully data-driven: it calls listForOrganization and renders a card per application using the registry metadata (name, icon, color, description, route). Nothing about specific applications is hardcoded in the launcher - change the registry and the launcher follows.
Next steps
- Building an application - the full recipe for a new vertical.
- Unified shell - how the launcher and mounts fit the shell.