Module Navigation Metadata
src/modules/module-nav-meta.ts resolves which frontend routes belong to
which module (resolveModuleNavMeta). The FE never hardcodes navigation —
it fetches this metadata; the AI assistant navigates through it; the
generated navigation-map.mdx doc derives from it.
Two sources, one contract (ModuleNavWithPrefixes)
Code
| Source | Owner | Use |
|---|---|---|
MODULE_NAV (static registry) | BE-owned modules only — currently just the reserved settings shell | Bootstrap nav for the platform itself |
service_registry.endpoints.nav | Service-owned modules (job, emailsender, crm, …) | Self-declared in the service.register payload, validated at enrollment by validateNavDeclaration — a malformed declaration is rejected and can never poison /modules or the census |
A module presents only its own nav — Settings MUST NOT declare links to
service-owned pages (a service going offline would orphan them, and the
module would masquerade as part of Settings). Settings therefore declares
only the prefix its own routes live under — /system/settings — never a
/system catch-all.
Root level (part_of) — module namespace
Every module has a root level (service_registry.part_of, persisted at
registration) and a module code (service_registry.code). Together they
form the module's single route namespace:
- Standard:
/{part_of}/{code}—job(part_of: "system") owns/system/job/**;settings(part_of: "system") owns/system/settings/**. - Collapse: when
part_of === codethe module IS its own root level and the segment collapses to/{code}—crm(part_of: "crm") owns/crm/**.
validateNavDeclaration enforces this at service.register time:
route_prefixes must CONTAIN the computed primary prefix, and every
nav[].href and routes[].route must live under SOME declared prefix —
a module can never declare pages on a foreign prefix or a foreign code.
Route-shape conventions — mechanically enforced. The same validator
(the shared @primebrick/sdk export, identical for service declarations,
the static MODULE_NAV, and unit tests) rejects:
kind:"list"with a non-plural leaf — list routes name the plural collection (/system/job/job-schedules,/system/settings/users);pluralizedecides,[param]leaves are exempt.kind:"create"not ending in/create—createis the single canonical suffix;/newor any other leaf is refused at enrollment.kind:"detail"not ending in[param]— detail routes address one record (/users/[uuid],/modules/[code]).- nav leaf
hrefresolving to no declared route and not plural — every sidebar link must point at a census route or name a plural collection; a dangling singular leaf is a declaration bug, not a style choice.
Paths are derived, never literals: entityPaths(prefix, listLeaf, detailParam?) (SDK) is the single constructor —
{list, create, detail} for one entity collection. Services declare
routes through it and unit-test validateNavDeclaration at write time;
the static MODULE_NAV goes through the same validator in CI. A
convention violation fails before the declaration ever reaches the
registry — today and on every future module.
Extra root memberships (multi-root): route_prefixes may add entries
of the form /{other-root}/{code} — the module code stays the URL's
second segment under every root. A crm service can therefore own /crm/**
(primary) AND /system/crm/** (extra): the pipeline page lives at
/system/crm/pipeline while customers stay at /crm/customers. Each
extra prefix materializes its first segment as an additional app level in
the FE launcher; ownership still resolves by longest-prefix. Single-segment
extras (/foo) and foreign-code segments (/system/other) are rejected.
Default module (is_default): service_registry.is_default marks the
module as the default of its OWN root (part_of). At most one winner per
root: a registration claiming is_default demotes the previous winner
(keep-winner policy — a re-registering service never downgrades an
admin's later choice; the claim is an upgrade-only flag). /api/v1/modules
returns part_of + is_default so the FE app launcher can group modules
by app level and resolve app-click → default module → first page.
Module display name (label_key): service_registry.label_key is the
i18n key for the module's localized name, resolved against the module's
OWN translation namespace — the same contract as nav[].label_key. The
service declares it at register time (labelKey option → label_key
payload) and seeds the key in its own *.translations schema. Every UI
surface that renders a module name — app launcher columns, breadcrumb
module menu (moduleMenuSegment), service-admin pages — resolves it via
resolveModuleLabel(module): t(label_key) with the raw name as the
only fallback when the key is absent or unresolved. Never render
service_registry.name directly in navigation surfaces, and never invent
per-page keys for the module name (settings → system.settings.title,
job → job.module.name, crm → crm.module.name).
Three layers, each with a distinct consumer:
| Layer | Purpose |
|---|---|
nav[] | The sidebar tree — label keys, icons, hrefs. FE renders it verbatim via fetchModuleMeta. Module home = nav[0].href (single source of truth for the module switcher) |
route_prefixes | Coarse ownership — "every path starting here belongs to this module" (last-resort coverage; powers isLinkActive longest-match in the sidebar) |
routes: PageRouteDeclaration[] | The complete census — every FE path the module owns, incl. detail/create/param routes not deducible from nav links, each typed list | detail | create | page, optionally bound to an entity and/or a remote component_url + component_format (esm-mount) |
Modules MUST declare every path they own. A route missing from the census is invisible to the AI and to the generated navigation map.
Icons: the module/service icon lives on service_registry.icon (the
sole module icon — there is no top-level nav.icon); per-page icons are
the nav[].icon fields and resolve through DynamicIcon.
The single discovery document — GET /api/v1/modules
ModuleDiscoveryService.list(user) emits, per enabled service, the
registry identity fields plus the resolved nav declaration —
nav + routes + per-root home (nav[0] of each root slice,
post-filter). This ONE payload feeds every nav surface: the launcher
columns, the sidebar, the breadcrumb, and the client-derived route census
(routesCensusFromModules in the FE). The former /modules/:code/meta
and /system/routes endpoints are retired — they were projections of
the same source and leaked module metadata regardless of access.
Access axis — access.{app}.{module}.{page}
Discovery is deny-first and access-filtered server-side: what a user
cannot reach is absent from the payload (non-disclosure), not emitted and
hidden client-side. access is a separate vocabulary inside the SAME
permission pipeline as entity RBAC (role_mappings → expanded set →
wildcards, evaluated by isAccessGranted in @primebrick/sdk):
| Declaration | Gate |
|---|---|
ModuleNavDeclaration.access | The WHOLE module — access.{app}.{module} exactly (e.g. access.system.settings); a page grant never satisfies it |
nav[].access / routes[].access | One page — access.{app}.{module}.{page} or deeper |
Semantics:
- Hierarchical — a bare grant covers its subtree:
access.system.job⇒access.system.job.rules. Wildcards work at any level (access.system.*,access.*). A page-level grant never climbs to its module ancestor. - Namespace-enforced at enrollment —
validateNavDeclarationrejectsaccesskeys outside the module's own namespace (accessNamespace(code, partOf)=access.{partOf}.{code}, collapsing toaccess.{code}whenpart_of === code— the same rule as URLs). is_adminimpliesaccess.systemONLY — platform plane. Business roots (access.crm.*,access.shop.*, …) require explicit grants even for admins.- Undeclared = open to authenticated users (backward compatible).
- A nav link whose children survive filtering stays as a group header.
Settings carries access: "access.system.settings" — normal users do not
see it; admins do via the implied access.system grant.
Consumers
- FE sidebar —
AppSidebar→GET /api/v1/modules→ renders the selected module'snav[]slice. Nested routes activate exactly one link: the longest matching href wins. - FE route census — derived client-side from
ModuleInfo.routes+ nav hrefs (routesCensusFromModules). Powers the guide assistant'ssearch_routesclient tool and remotecomponent_urlresolution — already access-filtered. generate:docs—generate-reference-docs.tsiterateslistModuleNavCodes()+ the service registry to emitdocs/user-guide/generated/navigation-map.mdx(route → module → kind → entity). No FE file scanning — the metadata already knows everything a+page.sveltescan could find and more.
Adding a service module's routes
Publish endpoints.nav in the service's service.register payload —
route_prefixes + nav[] + full routes census. One declaration makes
the module appear in the sidebar, the census, the AI's navigation answers
and the generated doc at the next generate:docs. BE-owned modules add a
case to MODULE_NAV instead.