Module Authoring Guide

Монгол  ·  English

Welcome to the Gerege Nexus Module Authoring Guide! This guide explains how external developers can write, register, and distribute custom business application modules for the platform.


Module architecture overview

In Gerege Nexus, business modules are written in Go as compile-time packages under backend/internal/apps/. (The Go module path stays github.com/gerege-systems/open-gerege-mn-erp/backend — an identifier, not a brand.)

Every module MUST implement the Module interface defined in backend/internal/module.go:

type Module interface {
    ID() string
    Name() string
    Version() string
    Dependencies() []Dependency
    Permissions() []PermissionDefinition
    Menus() []MenuDefinition
    RegisterRoutes(r chi.Router, tenantAuthMiddleware func(http.Handler) http.Handler)
}

Quick start: scaffold a module in one command

make new-app slug=helpdesk name="Helpdesk" name_mn="Тусламжийн төв"
# or: ./scripts/new-app.sh helpdesk "Helpdesk" "Тусламжийн төв"

This generates the backend module (full Module implementation with tenant-scoped CRUD), the goose migration, the manifest, the catalog/apps.json entry with translations.mn, and a frontend page — and wires the module into apps.Build (backend/internal/apps/runtime.go) and the menu blueprints automatically (with a printed manual instruction if an anchor is not found). Then run the migration and restart the API. The steps below explain what the generator produces.


Step by step: creating a new module

Step 1: Define Module Struct & Register in appregistry

Create a new directory backend/internal/apps/invoices/invoices.go.

The module type is unexported (module, not Module): what the outside may see is the constructor and the internal.Module interface methods, and nothing else. New returning an unexported type is fine — the composition root (apps/runtime.go) holds the value as an internal.Module. The rule is held by internal/architecture/export_surface_test.go.

package invoices

import (
    "net/http"
    "github.com/go-chi/chi/v5"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/gerege-systems/open-gerege-mn-erp/backend/internal"
    "github.com/gerege-systems/open-gerege-mn-erp/backend/internal/platform/appregistry"
)

type module struct {
    db *pgxpool.Pool
}

func New(db *pgxpool.Pool) *module {
    m := &module{db: db}
    appregistry.Register(m)
    return m
}

func (m *module) ID() string      { return "io.example.invoices" }
func (m *module) Name() string    { return "Invoicing & Billing" }
func (m *module) Version() string { return "1.0.0" }

func (m *module) Dependencies() []internal.Dependency {
    return []internal.Dependency{
        {ID: "io.example.contacts", VersionConstraint: "^1.0.0"},
        {ID: "io.example.products", VersionConstraint: "^1.0.0"},
    }
}

Step 2: Define Permissions and Menus

func (m *module) Permissions() []internal.PermissionDefinition {
    return []internal.PermissionDefinition{
        {Code: "invoices.read", Name: "View Invoices"},
        {Code: "invoices.manage", Name: "Create & Edit Invoices"},
    }
}

func (m *module) Menus() []internal.MenuDefinition {
    return []internal.MenuDefinition{
        {ID: "menu_invoices", Label: "Invoices", Path: "/invoices", Icon: "file-text", Order: 30},
    }
}

Step 3: Register HTTP Routes with App Gate Middleware

The middleware handed to RegisterRoutes is the platform's app gate: session authentication + an app_installations check for the tenant + the default permission mapping (GET/HEAD → <prefix>.read, other methods → <prefix>.manage).

func (m *module) RegisterRoutes(r chi.Router, tenantAuthMiddleware func(http.Handler) http.Handler) {
    r.Route("/api/v1/invoices", func(sub chi.Router) {
        sub.Use(tenantAuthMiddleware)
        sub.Get("/", m.handleListInvoices)
        sub.Post("/", m.handleCreateInvoice)
    })
}

Public routes, and the rule about them

RegisterRoutes receives the root router, not a pre-gated group. Mounting a path outside tenantAuthMiddleware is one line and looks exactly like mounting one inside it.

That is deliberate: a module may need to serve a caller who holds no session. The cost is that a private route can become public by accident, and nothing in the diff would say so. So the rule is:

A route reachable without a session must be named in publicRoutes in backend/internal/platform/route_policy_test.go.

The test walks the real routing table, calls every route with no credentials, and fails on anything that answers 200 or 201 without being on the list. It also fails on a name in the list that nothing serves any more, so a renamed route cannot leave an entry behind that quietly widens the next route to take its name.

Adding a name is then a visible act in a review. When you add one, say in a comment beside it which authority the route relies on instead of a session (a signature, a client secret, a single-use token).

Step 3.5: Add a database migration

If your module owns tables, add a goose migration under backend/db/migrations/control/000NN_<module>.sql (next free number, both -- +goose Up and -- +goose Down sections). Runtime DDL is forbidden — the schema must come from migrations only. Scope every table with a tenant_id column and put it in the unique constraints (UNIQUE (tenant_id, ...)).

During the schema-per-tenant transition (Phase A) a business table lives in two places: control/ (the historical public copy) and db/migrations/tenant/ (the line applied to every tenant schema). When adding or altering a business table, add the same DDL to tenant/ as a NEW numbered migration. Never edit the 0001_tenant_init.sql baseline.

No foreign keys into public. A tenant schema has to stand on its own: tenant_id and created_by simply hold control-plane ids. One such key puts EVERY schema in the database into the lock set of any statement touching public.tenants — one delete takes fifteen thousand locks at a hundred workspaces — and deadlocks against provisioning. tenant/0005 has the full reasoning.

Step 4: Create App Manifest JSON

Add a manifest file in catalog/manifests/invoices.json:

{
  "id": "io.example.invoices",
  "name": "Invoices",
  "version": "1.0.0",
  "platform": ">=0.1.0 <2.0.0",
  "dependencies": [
    { "id": "io.example.contacts", "version_constraint": "^1.0.0" },
    { "id": "io.example.products", "version_constraint": "^1.0.0" }
  ]
}

The field names must match appcatalog.Manifest exactly:

Field Type Notes
id string Must equal the id of the matching catalog/apps.json entry
version string Valid semver
platform string Semver constraint checked against the platform version (1.0.0)
dependencies array of {id, version_constraint} Not an object — {} fails to parse
permissions array of objects {code, name, description} Only with kind: "external". A compiled app declaring these is refused at boot — see the note below
kind internal (default) or external Empty means internal — everything else in this guide is about those
external Object — only with kind: "external" See §External apps below

The file name must be catalog/manifests/<slug>.json, where <slug> is the slug used in catalog/apps.json (lowercase letters, digits, - and _). A manifest that fails to load or whose id disagrees with the catalog entry is a startup error — the server refuses to boot rather than silently installing the app with an empty dependency, permission and menu set.

Permissions and menus are NOT in the manifest. A compiled app's permissions come from Permissions() and its menus from Menus() — the code that enforces a permission is the code that should declare it.

The manifest used to carry both, and this guide used to say "keep the two in sync". That advice could not work: nothing read the manifest's copy, so a divergence was invisible, and all ten manifests ended up with a menu order that disagreed with their module's (billing said 40, the code said 20). The menus field is gone, and a compiled app declaring permissions is now refused by ValidateManifest at boot — the only reliable way to keep two copies in sync is not to have a second one.

An external app is the opposite case: it has no Go module, so its manifest is the only statement there is, and permissions are required there.

External apps (kind: "external")

An app does not have to be compiled into this binary. With kind set to external the app runs somewhere else and talks to the platform over OAuth2/OIDC; the manifest says where the tile points, which redirects it has, and what it is asking for:

{
  "id": "com.vendor.crm",
  "kind": "external",
  "external": {
    "launch_url": "https://crm.vendor.example/app",
    "redirect_uris": ["https://crm.vendor.example/oidc/callback"],
    "scopes": ["openid", "contacts.read"],
    "event_types": ["billing.invoice.created"],
    "webhook_url": "https://crm.vendor.example/hooks/gerege"
  }
}

Validation happens where the manifest is read — at boot — rather than being discovered later by somebody staring at a redirect that goes nowhere:

  • launch_url must be absolute (an app running elsewhere is the definition)
  • HTTPS is required; only loopback (localhost, 127.0.0.1, ::1) is excepted, so a developer can point the catalog at their laptop
  • at least one redirect_uris entry — otherwise the OAuth client has nowhere to return to
  • event_types without a webhook_url is refused: the subscription would have nowhere to deliver
  • an external app may not declare module dependencies; it calls the API and cannot be ordered against the module graph

The installer looks for no compiled module for these, and takes their permissions from the manifest.

Step 5: Register the app in catalog/apps.json

Add an entry to catalog/apps.json to index the new app in the App Store. The apps database table is synchronised from that file on every boot, so no manual SQL is required. Each entry carries these fields:

{
  "id": "io.example.invoices",
  "slug": "invoices",
  "name": "Invoices",
  "description": "Invoice management",
  "icon_url": "/icons/invoices.png",
  "category": "Finance",
  "visibility": "public",
  "version": "1.0.0",
  "translations": {
    "mn": {
      "name": "Нэхэмжлэх",
      "description": "Нэхэмжлэхийн удирдлага",
      "category": "Санхүү"
    }
  }
}

The translations.mn block is what makes the App Store Mongolian-first — do not omit it. id must equal the manifest's id, and slug must equal the manifest's file name.

Step 6: Wire up the composition point and the frontend

  1. Instantiate the module once in apps.Build (backend/internal/apps/runtime.go) alongside the other modules and add it to the slice it returns — the constructor self-registers in appregistry. There is no route wiring to write: the server mounts every module behind the app gate keyed on its own ID().

    The platform does not import the apps. internal/platform knows only the internal.Module interface; the process composes the set (cmd/api calls platform.NewServer(db, catalogPath, platform.WithModules(apps.Build))). That is what keeps a module extractable without surgery on the core, and backend/internal/architecture/platform_boundary_test.go guards it — an app import anywhere under internal/platform fails the test.

  2. Register a menu blueprint in backend/internal/platform/menu/ (blueprints map) if your app should show grouped sidebar menus. A missing blueprint means the sidebar menu for your app does not render — this exact bug shipped once (commit c8e8c25, esign module).

  3. Add the frontend page: the app root at frontend/app/<module>/page.tsx. Blueprint sub-screens resolve through the dynamic frontend/app/module/[app]/[feature]/ route; paths without a real page show a "coming soon" placeholder.



Talking to other modules

A module that needs another module's data must go through its exported service API, never its tables. See docs/RESEARCH_ERP_MODULARITY.md for why every ERP converges on this.

The consumer declares the narrow interface it needs at the call site; the composition happens in the module's own constructor:

// billing declares only what it uses — not contacts' whole surface.
type contactLookup interface {
    Get(ctx context.Context, tenantID, id string) (*contacts.Contact, error)
}

func New(db *pgxpool.Pool) *billingModule {
    m := &billingModule{db: db, contacts: contacts.NewService(db)}
    appregistry.Register(m)
    return m
}

Rules that follow from that:

  • Reference by ID, with a foreign key. Store contact_id, not a copy of the contact.
  • Snapshot what is legally significant. An invoice must show the party as it was at issuance, so billing copies the resolved name into contact_name — and a later rename never rewrites the document.
  • Dependency direction is one-way. billing → contacts is declared in the manifest; contacts must not learn about billing.
  • Master-data modules stay at the bottom. contacts, products and friends export Get plus an ErrNotFound sentinel and depend on nothing above them, which keeps the module graph acyclic by construction.

Translations

Every user-facing string belongs in frontend/src/lib/i18n/addons/<app>.ts with keys shaped <module>.<kind>.<term>. Never write a literal in a component, and never branch on the locale in code. See TRANSLATION_GUIDE.md.

Backend menu labels carry their Mongolian text in the Labels map of the MenuDefinition, and app-store copy lives in catalog/apps.json under translations.mn.


Maintainers