PrimeBrickPrimeBrick
  • Docs
  • Contact
  • MIT License
  • Documentation
  • MCP Server
  • API Catalog
  • Services
  • Libraries
PrimeBrickPrimeBrick

© 2026 PrimeBrick. MIT License. v3.11.2

github
Backend
    Roles and PermissionsWorking Together on the Same RecordManaging ModulesSigning In and MFA
    Generated
    Manual
    Developer Guide
      OverviewArchitectureAPI ConventionsEntity metadata contractEntity CRUD PipelineEntities and CRUD conventionsModule Navigation MetadataService RegistryMCP GatewayThe Service ProxyRotating Secrets EngineTranslationsAuthenticationRBACEnvironment ConfigurationCollaboration & Visual Merge
Frontend
Microservices
powered by Zudoku
Developer Guide

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
{ module: "crm", route_prefixes: ["/crm"], // coarse ownership is_reserved?: true, access?: "access.crm", // module-level gate nav: [ {id, label_key, href, icon, access?, children?} ], // sidebar tree routes: [ {route, kind, entity?, label_key?, access?, component_url?, component_format?} ], }
SourceOwnerUse
MODULE_NAV (static registry)BE-owned modules only — currently just the reserved settings shellBootstrap nav for the platform itself
service_registry.endpoints.navService-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 === code the 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); pluralize decides, [param] leaves are exempt.
  • kind:"create" not ending in /create — create is the single canonical suffix; /new or any other leaf is refused at enrollment.
  • kind:"detail" not ending in [param] — detail routes address one record (/users/[uuid], /modules/[code]).
  • nav leaf href resolving 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:

LayerPurpose
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_prefixesCoarse 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):

DeclarationGate
ModuleNavDeclaration.accessThe WHOLE module — access.{app}.{module} exactly (e.g. access.system.settings); a page grant never satisfies it
nav[].access / routes[].accessOne 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 — validateNavDeclaration rejects access keys outside the module's own namespace (accessNamespace(code, partOf) = access.{partOf}.{code}, collapsing to access.{code} when part_of === code — the same rule as URLs).
  • is_admin implies access.system ONLY — 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

  1. FE sidebar — AppSidebar → GET /api/v1/modules → renders the selected module's nav[] slice. Nested routes activate exactly one link: the longest matching href wins.
  2. FE route census — derived client-side from ModuleInfo.routes + nav hrefs (routesCensusFromModules). Powers the guide assistant's search_routes client tool and remote component_url resolution — already access-filtered.
  3. generate:docs — generate-reference-docs.ts iterates listModuleNavCodes() + the service registry to emit docs/user-guide/generated/navigation-map.mdx (route → module → kind → entity). No FE file scanning — the metadata already knows everything a +page.svelte scan 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.

Last modified on October 28, 2026
Entities and CRUD conventionsService Registry
On this page
  • Two sources, one contract (ModuleNavWithPrefixes)
  • Root level (part_of) — module namespace
  • The single discovery document — GET /api/v1/modules
  • Access axis — access.{app}.{module}.{page}
  • Consumers
  • Adding a service module's routes
TypeScript