# 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`)

```ts
{
  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?} ],
}
```

| 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 === 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:

| 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** — `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.
