@netup/unified-admin-contract
v11.13.0
Published
Host ↔ module contract for the NetUP unified admin UI: TypeScript types, contract version, and pure compatibility checkers
Maintainers
Readme
@netup/unified-admin-contract
The host ↔ module contract of the NetUP unified admin UI: TypeScript types, the contract version constant, and pure compatibility checkers.
The NetUP unified admin is a shell (host) that mounts admin UIs of independently delivered modules — IPTV middleware, digital signage, VoD and custom third-party modules — under one menu, one URL and one session. This package pins down the boundary between the host and a module so that a module can be developed, versioned and shipped independently of the host and of other modules.
- Zero dependencies. Types and pure functions only.
- Plain-JS boundary. The contract passes callbacks, DOM nodes and observables — never framework objects. Host and modules may use different React/MUI/router versions, or no React at all. Methods are always called on their owner object, so either side may implement them with classes — see Methods are called on their owner.
- Degradation first. Additive contract extensions are optional fields with runtime feature-detection; version bumps happen only on breaking changes. See Compatibility policy.
Installation
npm install @netup/unified-admin-contractArchitecture
The host owns the master layout: the drawer menu, the app bar, snackbars, the
error log and top-level routing. Every module is mounted as an isolated root:
the host provides a DOM node, the module renders its whole UI tree into it
with its own framework copy, stores and contexts. There are no shared
framework singletons — everything the module needs from the shell comes
through a single HostApi object, and everything the host needs to know
about the module comes back through ModuleState reports and the
EmbeddedHandle returned by mount.
┌────────────────────────── host shell ──────────────────────────┐
│ drawer · app bar · snackbars · error log · top-level routing │
│ │
│ ┌── module root A ──┐ ┌── module root B ──┐ │
│ │ own React/MUI │ │ any framework │ │
│ │ own stores/i18n │ │ │ │
│ └───────▲───────────┘ └───────▲───────────┘ │
│ │ HostApi / EmbeddedHandle │ │
└───────────┴───────────────────────┴────────────────────────────┘Module isolation is a deliberate trade-off: bundles are larger (each module carries its own framework), but modules release independently, legacy modules can be frozen, and third-party modules do not have to track the host's internal library versions.
Module anatomy
A module is a self-contained web application plus a small contract surface:
- Remote entry. The module's UI ships as a
Module Federation bundle whose exposed
./modulefile default-exports aModuleManifest. - Isolated root. The module renders into a host-provided DOM node and
renders one active page at a time — the host owns the menu and the URL
and tells the module which page to show via
EmbeddedHandle.setPage. - Self-describing delivery. The module's container image declares unified-admin support in a docker label, so the host discovers modules without loading any code. See Building a module.
Building a module
End-to-end walkthrough for creating a module from scratch — frontend, backend, and the container that delivers both.
1. Write the manifest
Create a file that default-exports a ModuleManifest. mount receives the
host container node and the HostApi instance and returns an
EmbeddedHandle. See Examples for a complete minimal module.
// src/manifest.ts
import { CONTRACT_VERSION, ModuleManifest } from '@netup/unified-admin-contract';
const manifest: ModuleManifest = {
id: 'acme',
contractVersion: CONTRACT_VERSION,
title: 'ACME',
mount(container, hostApi) {
/* mount your UI into `container`, wire it to `hostApi` */
},
};
export default manifest;2. Build the remote entry (Vite + Module Federation 2)
The host loads modules with the Module Federation 2 manifest protocol: it
fetches mf-manifest.json, then the remoteEntry.js it references, then
takes the default export of the exposed ./module.
// vite.config.ts
import { federation } from '@module-federation/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
federation({
name: 'acme',
filename: 'remoteEntry.js',
manifest: true,
exposes: { './module': './src/manifest.ts' },
shared: {},
}),
],
build: { target: 'esnext' },
});Keep shared empty (or minimal and deliberate): the module is expected to be
self-contained, nothing is forced into a host-shared singleton.
3. Backend, static files and the nginx route
The module's container runs its own backend process listening on a unix
socket under /run/netup/ (module containers use host networking and share
/run with the platform). The backend serves the module's API and the
frontend static files — including mf-manifest.json and remoteEntry.js —
under the module's web root.
All HTTP enters through the platform's nginx: it validates the admin session
and proxies /<module-base>/… requests to the module's socket. Routes for
the built-in NetUP modules ship with the platform; a custom module adds its
route to the persistent nginx include
/netup/sysconfig/nginx/default_server.conf (included inside the server
block; the file survives reboots and platform updates). A minimal route:
location /acme/ {
add_header Cache-Control $mf_cache_control always;
auth_request /auth;
error_page 401 /login_wrapper.html;
proxy_pass http://unix:/run/netup/acme.sock:/;
}auth_request /authplugs the module into the platform's single sign-on: nginx validates the admin session and forwards the verified operator identity to your backend (see below).$mf_cache_control(a map defined by the platform config) marksremoteEntry.js/mf-manifest.jsonasno-cache, so the shell never sticks to a stale bundle after a module update.- Add dedicated locations for WebSocket endpoints (
Upgrade/Connectionheaders) and large uploads (client_max_body_size) as needed.
Identity headers. Behind auth_request every proxied request carries
the authenticated operator's identity:
| Header | Value |
| -------------- | --------------------------------------------------------------------------------------------------- |
| X-Auth-Login | Platform login of the operator (for an API-token request — the token owner's login) |
| X-Auth-Roles | The operator's roles, comma-separated, expanded and deduplicated; absent when the user has no roles |
| X-Auth-Token | API-token id when the request was authenticated with a token (for audit trails) |
Each token (the login, every role name) is percent-encoded individually
(RFC 3986), so the comma separator is unambiguous: split on ,, then
percent-decode each token. nginx always overwrites these headers — a client
cannot spoof them — so the backend can base its own authorization on them.
NetUP modules build a per-role permission matrix on top of X-Auth-Roles
(enforced server-side by the module) and expose its editor to the shell via
hasPermissions/mountPermissions — see
Permissions editor fragment.
The host passes the resolved web root to the module at mount time as
HostApi.baseUri — build your API and asset URLs from it rather than from
document.baseURI (which belongs to the host page in the unified shell).
4. Package the container
A module is delivered as a docker image with tv.netup.service.* labels; the
platform inventories containers by these labels.
FROM debian:trixie-slim
COPY backend /srv/acme/
COPY frontend/dist/ /srv/acme/www/
CMD ["/srv/acme/acme-backend"]
LABEL tv.netup.service.name=acme \
tv.netup.service.unified-admin='{"contract":11,"id":"acme","remoteEntry":"remoteEntry.js","mfManifest":"mf-manifest.json"}' \
tv.netup.service.version=1.0 \
tv.netup.service.build=42| Label | Purpose |
| ------------------------------------ | ------------------------------------------------------------------------------------------- |
| tv.netup.service.name | Service name; the key the host maps to the module's web root |
| tv.netup.service.unified-admin | JSON UnifiedAdminDescriptor: declares unified-admin support before any code is loaded |
| tv.netup.service.version, .build | Code identity; the host watches them to detect a newly deployed module version (hot update) |
| tv.netup.service.constraints | Optional JSON: dependency/conflict semver ranges on other packages (see below) |
| tv.netup.service.provides | Optional JSON: services inside the container, drives platform log rotation (see below) |
| tv.netup.service.mounts | Optional host directories the container needs |
Descriptor fields. The shell loads a module from mfManifest: the
Module Federation manifest names the entry file itself. remoteEntry is
declarative — keep it truthful, but a host is not required to read it, and
the NETUP shell does not. contract and id are the two fields every host
acts on.
Dependencies and conflicts. tv.netup.service.constraints is a JSON
object with optional dependencies and conflicts maps: package name →
semver range, checked against that package's installed <version>.<build>
(see "Version format" below). Package names are service names (system,
netup_nginx, mw, …) plus the platform pseudo-packages firmware and
linux_kernel. A dependency is broken when the package is missing or its
version is outside the range; a conflict fires when the package is installed
and its version falls inside the range. The platform re-evaluates the whole
set on every install/update/remove and warns the operator about violations
the action would introduce, asking for confirmation.
{
"dependencies": { "firmware": ">=trunk.232", "netup_nginx": ">=master.4275", "system": ">=master.4275" },
"conflicts": { "acme-legacy": "*" }
}Always declare a dependency on a system version that implements the
contract major your module is built with — then installing the module onto
an older platform warns the operator right at install time, instead of the
module silently being skipped by the shell at runtime (the runtime
exact-major check still applies; the constraint just surfaces the problem
earlier). Contract major 11 ships with system master.4271 and newer; the
identity headers from step 3 are forwarded since master.4275 — hence the
"system": ">=master.4275" pin in the example above, covering both.
Log rotation. tv.netup.service.provides is a JSON array declaring the
services running inside the container; the platform's log rotator uses it.
For each entry it renames the files listed in logs (relative to the shared
/netup/log directory) and then executes rotateCmd inside the container —
the command must make the process reopen its log files:
[{ "name": "acme", "rotateCmd": "pkill -USR2 -x acme-backend", "logs": ["acme.log"] }]Version format. The platform treats <version>.<build> as the module's
full version and compares it with semver logic (dependency and conflict
ranges in tv.netup.service.constraints, "newer version deployed"
detection). Keep tv.netup.service.version numeric (major.minor, e.g.
1.0) and tv.netup.service.build a plain number. The string values
master and trunk are reserved for NetUP's own CI builds and are handled
specially (they always compare as newer than any numeric version) — do not
use them for a custom module.
The contract field of the label must equal the CONTRACT_VERSION your
bundle was built with — derive it from the installed package at build time
instead of hard-coding, so the label can never disagree with the runtime
manifest:
CONTRACT=$(node -p "require('@netup/unified-admin-contract/package.json').version.split('.')[0]")Ship the image as an .nsp.xz archive:
docker save acme:1.0.42 | xz > acme-1.0.42.nsp.xz5. Install and discovery
Install the archive on the NetUP server (the "Modules & services" page of the
System admin, or create-service on the command line). Discovery is then
automatic:
- The host backend reads docker labels and exposes them to the shell.
- The shell filters running services carrying the
unified-adminlabel and checks the label'scontractagainst its ownCONTRACT_VERSION(exact-major, see Compatibility policy). - For compatible candidates it loads
mf-manifest.json→remoteEntry.js→ theModuleManifest, and re-checksmanifest.contractVersion. - Incompatible or broken modules are skipped with a visible indication in the shell — they never break other modules.
Base URI registration. The nginx route is under your control (see step 3), but the shell's mapping "service name → web root" is currently a fixed registry inside the platform. A third-party module needs its service name and base path registered on the platform side — contact NetUP when planning a custom module.
6. Verify
With the module installed and running: the unified admin card appears on the System home screen, the module's pages appear in the unified menu, and the "Application information" dialog lists the module with its versions. If the module reports missing host features or a pair mismatch, the shell shows a degraded-state warning there.
Module manifest
interface ModuleManifest {
id: string;
contractVersion: number;
title: string;
order?: number;
remoteEntry?: string;
mount(container: Element, hostApi: HostApi): EmbeddedHandle;
}id— unique module id; also the URL segment of the module inside the shell.contractVersion— the contract major the bundle was built with; exportCONTRACT_VERSIONfrom this package, never a literal.title— human-readable module name; the host labels the module with it until the module reports a localized one, see Module display name.order— drawer ordering priority: the host sorts ascending, ties broken byid, absent value sorts last.mount— the single entry point of the module's embedded UI.
The manifest is deliberately thin. Everything dynamic — menu pages, feature
flags, settings presence — travels through the
ModuleState report after mount, because the
manifest is read before mounting and has no degradation channel.
Mount lifecycle
mount(container, hostApi) must:
- Render the module UI into
container(an empty block element owned by the host; the host never touches its children). - Subscribe to the
hostApiobservables it needs (theme,locale, optionallylicense). - Report its state via
hostApi.setModuleState(and keep re-reporting when locale, license or feature flags change visibility or titles). - Return an
EmbeddedHandle.
interface EmbeddedHandle {
store: unknown; // deprecated: no host reads it, removal scheduled for the next major
setPage(page: string): void;
mountSettings(node: HTMLElement): Unsubscribe;
mountVersionInfo(node: HTMLElement): Unsubscribe;
mountPermissions?(
node: HTMLElement,
role: string,
context?: Observable<PermissionsEditorContext | undefined>
): Unsubscribe;
mountArea?(id: string, node: HTMLElement, context: Observable<AreaContext>): Unsubscribe;
unmount(): void;
}setPage(page)— the host resolved the shell URL to a module page path; the module renders that page. The module never mounts its own top-level router UI (drawer, app bar, footer) — the host owns them.mountSettings(node)— render the module's settings tab content into a host-provided node (the host builds a per-module tab in its settings dialog whenModuleState.hasSettings/hasFeatureFlagsis reported). Returns a cleanup function.mountVersionInfo(node)— render the module's version rows for the host's "Application information" view. The node is a table body of the host's section table, so render table rows into it — see Settings and version info.mountPermissions(node, role, context?)— optional; see Permissions editor fragment.mountArea(id, node, context)— optional, donor side of Area contributions.unmount()— tear everything down: unsubscribe, stop timers/sockets, unmount the root. The host calls it when leaving the shell and before re-mounting a newly deployed module version (hot update).
All mount* methods are portals: the content they render stays inside the
module's own component tree (so stores, contexts and translations keep
working) while being displayed in a host-owned DOM node.
HostApi reference
interface HostApi {
contractVersion: number;
baseUri: string;
capabilities?: readonly HostCapability[];
license?: Observable<LicenseDocument | undefined>;
areas?: AreaBroker;
search?: Observable<string>;
visible?: Observable<boolean>;
reportError(error: ErrorData): void;
enqueueSnackbar(snackbar: SnackbarRequest): void;
setModuleState?(state: ModuleState): void;
routing: RoutingSource;
theme: Observable<ThemeMode>;
locale: Observable<string>;
shell: {/* see Shell channel */};
}Each module root receives its own HostApi instance. Optional fields are
additive contract extensions — always feature-detect (see
Compatibility policy).
Observable<T> is the minimal push primitive of the contract:
interface Observable<T> {
get(): T;
subscribe(listener: (value: T) => void): Unsubscribe;
}Methods are called on their owner
Every method of this contract is invoked on the object that carries it —
hostApi.theme.subscribe(listener), hostApi.shell.setTitle(title),
handle.setPage(page) — never through a reference detached from that object.
The contract therefore does not require closure-based objects: a host is free
to implement Observable, RoutingSource, shell or any other member with
classes, getters or prototype methods that need this. The same holds in the
other direction — a module may return an EmbeddedHandle whose methods live on a
prototype.
React makes detaching easy to do by accident: passing subscribe straight into
useSyncExternalStore hands over the method without its owner. Wrap it instead
(listener => source.subscribe(listener)) and keep the wrapper stable — memoize it
per channel, because useSyncExternalStore resubscribes whenever the subscribe
reference changes. Feature-detecting an optional method is a check of the field
(if (hostApi.shell.setSearchShown)), while the call still goes through the owner.
Routing
The shell URL is one URL for the whole unified admin; the module's internal
navigation is namespaced under /<module-id>/… by the host. The host hands
the module a low-level RoutingSource:
interface RoutingSource {
getSnapshot(): { pathname: string; search: string };
subscribe(listener: () => void): Unsubscribe;
navigate(to: To, options?: { replace?: boolean; state?: unknown }): void;
createHref(to: To): string;
getParams?(): Record<string, string | undefined>;
back?(): void;
}Paths are module-relative: the host strips its own prefix before calling the
module and prepends it inside navigate/createHref. back is optional
(additive): a module must fall back to its own navigation when it is absent.
getParams is deprecated and scheduled for removal in the next major: no
host implements it. Resolve path parameters by matching getSnapshot().pathname
against your own route patterns.
RouterAdapter is the hook-shaped view of the same source for React-based
modules; To, NavigateOptions and SetURLSearchParams are structural
copies of the react-router v7 types, assignment-compatible in both
directions, so react-router values pass straight through.
Theme and locale
hostApi.theme ('auto' | 'light' | 'dark') and hostApi.locale (BCP-47
language tag) are host-owned observables. The module applies them to its own
theming and i18n instances — there is no shared theme or i18n singleton, so a
module can localize with any library and any set of languages.
Errors and snackbars
The module forwards errors and notifications to the host so the user gets one error log and one snackbar stack:
reportError(error: ErrorData)— append an entry to the host error log.ErrorDatais a plain record:time(epoch ms),message, optionalparams, anidunique within the module's log, and the optional requesturlthe failure came from.enqueueSnackbar(snackbar: SnackbarRequest)— show a snackbar;type,title,persist,timeoutand one optionalactionbutton are supported.
All strings are pre-localized by the module.
SnackbarRequest.timeout is how long the snackbar stays, in milliseconds. Omit
it and the host applies its own default; persist: true keeps the snackbar
until the user closes it, and then timeout is ignored. A module needs the
field for notifications worth showing but not worth reading — "upload started"
next to a progress indicator that already says so — where the host default
outstays its welcome and covers the app bar. Passing it is unconditional, like
ErrorData.url: a host that ignores the field applies its default, which is
what the module got before the field existed.
ErrorData.url lets the host tell which service the request went to, not
just which module reported it. A module may call another module's API (its own
API is not the only address behind the shell), so a stopped service produces
failures reported by a module that is perfectly healthy. A host that reads url
can attribute those failures to the stopped service and count them instead of
filling its error log with one entry per retry; a host that ignores the field
behaves exactly as before. Reporting it is unconditional — no capability to
feature-detect, since nothing in the module depends on the host reading it.
Shell channel
hostApi.shell carries the module's per-page shell state:
shell: {
setDrawerHidden(hidden: boolean): void;
setFooterHidden(hidden: boolean): void;
mountAppBarIcons(node: HTMLElement | null): void;
setAppBarIcons?(nodes: ShellAppBarNode[]): void; // see App-bar icon scale
mountCustomAppBar(node: HTMLElement | null): void;
setTitle(title: ShellTitle): void;
setPreventUnload(reasons: ShellPreventReason[]): void;
setUploadState(items, onAction): void; // see Upload state channel
setSearchShown?(shown: boolean): void; // see Search channel
}setTitle— a plain string or an array ofShellBreadcrumbs; crumbhrefs must be pre-resolved withrouting.createHrefso they are clickable host hrefs. The last crumb omitshref.setPreventUnload— the module's unsaved-changes guard: while the list is non-empty the host blocks leaving the shell (andbeforeunload) and shows the reasons. In-module navigation is not blocked by the host — that stays the module's business.mountAppBarIcons/mountCustomAppBar— portals for the module's app-bar icons or a full app-bar replacement, rendered by the module root into host-owned nodes. Passnullto detach a previously mounted node. A full-screen page is the composition: hidden drawer + custom (or empty) app bar.setAppBarIcons— the same icons, but as separately placed nodes, so the host can interleave them with its own; see App-bar icon scale.setDrawerHidden— hide the shell drawer for the active page.setFooterHidden— deprecated, scheduled for removal in the next major. A host only hides what it draws, and no host draws a footer, so the call has no effect.setSearchShown— ask for the host app-bar search field, see Search channel.
App-bar icon scale
Every icon of the bar — the host's own, the standard ones the bar draws itself (uploads, error log, settings, application menu) and those of the embedded modules — sits on one ascending scale, so a module can place its icon next to a specific neighbour instead of landing wherever its group happens to be:
shell.setAppBarIcons?.([{ id: 'taskQueue', node, order: 590 }]); // module → host
shell.setAppBarIcons?.([]); // detachAn entry is a node with a place on the scale, not necessarily a single
icon: a module renders one node per icon when it wants them placed separately,
or one node for the whole group when it does not care. An icon without order
gets APP_BAR_ICON_ORDER.default.
Each call replaces the whole set. id is the module's own handle on a node —
unique within the module and stable across calls: the host keeps a node mounted
while its id stays in the set, and drops it once the id is gone.
Ties keep declaration order, so a group that must stay together is expressed by
one shared number. Across modules — where the shared default makes ties the
norm — the host breaks them the way it breaks menu ones, by
module order then id, so the bar does not reshuffle
between loads.
The scale is shared with the modules of the NetUP platform, and a value only
means something relative to what is already there. APP_BAR_ICON_ORDER names
the fixed points; the table is the reference — pick a free number between the
neighbours you want, in steps that leave room for later insertions.
| order | Icon | Owner |
| ----- | ------------------------------------------------- | ---------- |
| 100 | status — connection status (link lost, reboot) | host |
| 110 | module health | host |
| 500 | default — icons declaring no order of their own | any module |
| 590 | transcoding queue | ds |
| 600 | uploads — upload indicator | app bar |
| 650 | user menu | host |
| 660 | support proxy | host |
| 670 | power menu | host |
| 680 | mobile menu | host |
| 1000 | errors — error log | app bar |
| 1100 | settings | app bar |
| 1200 | menu — application menu | app bar |
The four owned by the app bar are the standard icons an application composes
its bar from. A host that draws its own user menu (the NetUP shell does) leaves
the application menu undrawn — its number and everything above it stay free.
Degradation. setAppBarIcons is optional. Against a host without it the
module falls back to mountAppBarIcons and its icons stay one group in
whatever place the host gives that group — the icons are all there, only their
order differs, so the module reports shell.setAppBarIcons in
ModuleState.cosmeticHostGaps rather than in missingHostFeatures, and only
when it actually declared an order the fallback cannot honour.
Search channel
The app-bar search field belongs to the host: it is a part of the bar the host draws, and only the host knows which module is on screen. A module page that searches its list asks for the field and reads the typed text back:
shell.setSearchShown?.(true); // module → host, per page
hostApi.search?.subscribe(text => ...); // host → module, the typed text- The host owns the text.
HostApi.searchis read-only for the module: the field, its clear button and its lifetime are the host's. A module that clears its own search state does not clear the host field. - Only the active module is heard. A hidden module root keeps its pages mounted, so it keeps asking for the field; the host applies the request of the module the operator is currently looking at and ignores the rest.
- The host resets the text when the active module or its active page changes — every page starts with an empty search, as it does standalone.
- Degradation. Both members are optional. Against a host without them the
module reports
shell.setSearchShown/searchinModuleState.missingHostFeatures(functional: the page loses its search), and a host that has them shows no field until some module asks for one.
Visibility channel
Only one module root is on screen at a time: the host keeps every installed
module mounted and switches which one is displayed. hostApi.visible tells a
root whether it is the one the operator is looking at, so a hidden root can stop
working in the background:
hostApi.visible?.subscribe(visible => ...); // host → moduleWhat a hidden root is expected to suspend is its page: unmount the page
subtree so its data subscriptions and polling timers stop. What it must keep
alive is everything the host displays outside the page — app-bar icons, the
setModuleState report that builds the menu, area contributions mounted into
other modules' pages, and the settings tab. The host reports the fact and leaves
the policy to the module: only the module knows whether its page holds unsaved
work or a running upload.
- Suspend after a grace period, not immediately — switching to a neighbour module and back should not rebuild the page.
- Never suspend a page holding unsaved changes or an active upload.
- The URL survives. The host keeps addressing the same path, so a resumed page reopens on its tab, folder or detail view.
Degradation: the field is optional, and against a host without it a module never
suspends — exactly the behaviour it had before the channel existed. Nothing goes
into missingHostFeatures: no functionality is lost, only the saving (see
Compatibility policy).
Upload state channel
The upload engine lives in the module (it knows its endpoints and processing); the indicator is host-owned and aggregates all modules. The module pushes its current upload list:
shell.setUploadState(items: ShellUploadItem[], onAction: (id: string, action: ShellUploadAction) => void)ShellUploadItem carries pre-localized displayName/statusText, a
status (UploadItemStatus), a progress fraction from 0 to 1 (not a
percentage — the host scales it for display), an optional error and an
optional doneAction (SVG icon markup + tooltip). The host renders the
aggregate indicator and calls onAction(id, 'abort' | 'retry' | 'remove' |
'done') back into the module. Active uploads of any module also feed the
host's leave-guard.
UploadItemStatus values and what a host is expected to offer for each:
| Status | Meaning | Actions |
| --------------------- | ---------------------------------------------------------- | ------------- |
| pending | queued, transfer not started | abort |
| uploading | transferring, progress meaningful | abort |
| processing | transferred, server-side processing, progress meaningful | abort |
| processing-orphaned | processing was lost track of (server restart, expired job) | retry, remove |
| done | finished; doneAction applies | remove |
| error | failed, error carries the reason | retry, remove |
| aborted | cancelled by the operator | retry, remove |
pending, uploading and processing count as active: a host that guards
against leaving the page while work is in flight keys that guard on them.
License
hostApi.license?: Observable<LicenseDocument | undefined> — the
host-aggregated license of the installation:
interface LicenseDocument {
systems: LicenseSystem[];
validity?: LicenseValidity; // the installation license itself
subject?: string; // who the license is issued to, plain text
serial?: number; // license serial number
}
interface LicenseSystem {
system: string; // system-type key, e.g. 'middleware', 'vod', 'digital_signage'
options: Record<string, LicenseOptionValue>;
unknownOid?: number; // numeric OID suffix when `system` is 'unknown'
validity?: LicenseValidity;
}
interface LicenseValidity {
notBefore: string; // ISO 8601
notAfter: string;
}subject and serial identify the license itself — who it is issued to and its
serial number — so a module can show them without asking the host's own API.
subject is plain text, ready to render. Both are optional: a module that builds
the document from its own narrower license API may not know them.
A licensed subsystem is present as an entry in systems; per-subsystem limits
and switches are its options, keyed exactly as the license carries them
('Ds devices limit', 'Personal Accounts Limit', 'MICROS-Fidelio Support').
system is a plain string, not a union: a host may license a subsystem this
package doesn't know yet, and 'unknown' plus unknownOid covers an OID the
host itself doesn't recognize.
Prefer this channel over fetching your own copy, and keep the fallback for two
cases: the field is absent (the host cannot serve the license — for
instance the signed-in operator may not read it), or it is present but its
value is still undefined (the host has no document yet). Gate on the value,
not on the field, and both cases degrade the same way.
Module state channel
hostApi.setModuleState?(state: ModuleState) — the module's live
self-description; the only source of the module's menu. See
ModuleState reference. Re-send it whenever locale
(titles), license, settings or feature flags change what the user should
see. The field is optional on the host: feature-detect before calling.
Area broker
hostApi.areas?: AreaBroker — the receiver side of
Area contributions. One broker instance is shared by
all module roots on the host side. getTargetPages?() additionally tells a
donor which receiving surfaces the shell currently has — see
Receiving surface presence, and
getContributionReasons?() tells a receiver about unsaved changes inside the
contributions it renders — see
Contribution unsaved changes.
ModuleState reference
interface ModuleState {
pages: Array<{ path: string; icon?: string; title: string; domain?: string; order?: number; topLevel?: boolean }>;
features: Record<string, boolean>;
domains?: ModuleDomain[];
title?: string;
hasSettings?: boolean;
hasFeatureFlags?: boolean;
hasPermissions?: boolean;
missingHostFeatures?: string[];
cosmeticHostGaps?: string[];
areaContributions?: AreaContribution[];
areaTargets?: AreaTarget[];
pending?: boolean;
}The report is wire-shaped plain data: pre-localized strings, icons as SVG markup, no functions except through the handle.
Pages and menu domains
pages— the currently visible pages with localized titles; the host cannot localize module strings, so the module re-reports on locale change.icon— plain SVG markup; the host inserts it as-is (there is no shared icon registry). Serialize your icon component/asset to SVG at report time.domain— pages of all modules are grouped into shared menu domains (e.g. "Devices", "Monitoring") instead of one-section-per-module. The module declares its domains indomains(id, localized title, optional SVG icon,order) and tags pages with a domain id. Two modules declaring the same domain id merge into one group (first-reported title/icon wins).order— one shared ordering scale across modules for pages within a domain group (and for domain groups themselves viaModuleDomain.order).topLevel— draw the page as a standalone menu item alongside domain groups rather than inside one.ModuleDomain.collapseInto— the id of another domain to move this group's single item into when the group ends up with one visible page, so a header never sits above a lone item. A module cannot decide this itself: whether its domain has neighbours depends on which other modules the installation has, and a module only knows its own pages. The host collapses only when the target group exists, otherwise the group is drawn as is — an item is never dropped. Declare the field on every declaration of the domain: the host merges domain metadata per field, first declaration wins.features— the module's active capabilities (license features, settings, feature flags, permissions) as a flat boolean map; other modules' contributions may condition on them.pending— the page list is preliminary, the data gating visibility (permissions, license) is still loading, see Preliminary reports.
Menu ordering scale
order is one scale, not one per kind: domain groups, topLevel pages and the
host's own menu entries are sorted together, so order: 25 lands between a group
at 20 and a group at 28 whatever those entries are. Pages inside a group are
sorted by their own order on a second scale, local to the group.
Both scales are shared with the modules of the NetUP platform, and a value only means something relative to what is already there. The tables below are that reference — pick a free number between the neighbours you want, in steps that leave room for later insertions.
Groups, top-level pages and outgoing links:
| order | Entry | Owner |
| ----- | -------------------------------- | -------------------- |
| 0 | Overview (top-level) | host |
| 5 | Overview / Dashboard (top-level) | mw, ds |
| 8 | Accounts & Devices (top-level) | mw, ds |
| 9 | link: DVB/HDMI/SDI input | host |
| 10 | link: video stream processing | host |
| 12 | iptv | mw |
| 15 | vod (collapses into iptv) | mw, md, vod |
| 20 | signage | ds |
| 28 | Stream redundancy (top-level) | rs |
| 31 | TV archive recording (top-level) | cu-admin |
| 34 | Multicast failover (top-level) | multicast-failover |
| 37 | orchestrator | orchestrator |
| 40 | messages | mw |
| 50 | monitoring | mw, ds |
| 60 | settings | mw, ds, host |
| 80 | Server settings | host |
| 90 | help | every module |
Pages inside those groups:
| Group | Page | Owner | order |
| --------------- | ---------------------------- | -------------- | ----- |
| iptv | TV channels | mw | 10 |
| | Radio channels | mw | 20 |
| | EPG | mw | 30 |
| | Channel groups | mw | 40 |
| vod | Movies | md | 10 |
| | Movies (legacy) | mw | 10 |
| | TV series | md | 20 |
| | VoD packages | mw | 30 |
| | Tags | md | 40 |
| | Content origins | md | 50 |
| | VoD storage | vod | 60 |
| signage | Media | ds | 10 |
| | Playlists | ds | 20 |
| | Layouts | ds | 30 |
| | Schedules | ds | 40 |
| | Widget data sources | ds | 50 |
| | Import sources | ds | 60 |
| orchestrator | Health | orchestrator | 10 |
| | Instances | orchestrator | 20 |
| | Inventory | orchestrator | 30 |
| messages | In-room orders | mw | 10 |
| | Emergency messages | mw | 20 |
| | System messages | mw | 30 |
| monitoring | Telemetry | mw | 10 |
| | Statistics | mw | 20 |
| | Statistics | ds | 40 |
| settings | Clients settings | mw | 10 |
| | System settings | mw | 20 |
| | Transcoding | ds | 30 |
| | Permissions | host | 40 |
| | Settings | ds | 50 |
| | Permissions | mw | 60 |
| | Permissions | ds | 70 |
| Server settings | Modules | host | 10 |
| | Firmware | host | 20 |
| | Backup | host | 30 |
| | License | host | 40 |
| | Network | host | 50 |
| | Routing | host | 60 |
| | Time | host | 70 |
| help | API reference of each module | every module | 10…70 |
Two pages share a number on purpose when they are alternatives that never show up
together: the mw "Movies (legacy)" takes the place of the md "Movies", so both
sit at 10.
Several numbers above belong to pages a full installation does not draw at all —
a page whose content arrives as a contribution to someone else's page while that
receiver exists (Overview and Dashboard, the ds Accounts & Devices, Statistics
and Settings — see
Receiving surface presence), or a module's
Permissions page superseded by the host's own. Their numbers still matter,
because the hiding is conditional: where the receiver is missing — the other
module is not installed, its page is gated off, the operator lacks the
permission, the host is older and reports no such surface — the page comes back
and sits next to pages of other modules, and without order its position would
depend on which modules happen to be installed.
Order collisions
Equal values are not an error and produce no warning, so a predictable menu relies on unique ones:
- equal
order— the tie is broken by traversal order: the module with the lowerModuleManifest.orderfirst (then byidlexicographically), the host's own entries last among equals; - no
order— sorts last; - one domain id declared by several modules — the metadata is merged per field,
first declaration wins, so a diverging
title,iconorordersilently loses to the earlier module. Declare a shared domain identically everywhere; - a page pointing at a domain it never declared — the host falls back to a per-module group and warns to the console;
- same-titled items inside one group — the host adds the module name as a subtitle instead of reordering.
Preliminary reports
A module usually reports its state before the data that gates page visibility —
permissions, license, feature flags — has arrived, so its first report carries
fewer pages than the final one. Set pending: true while that data is loading
and clear it once the state settles. The host relies on the flag twice:
- it tells "this module has no such page" from "this module does not know yet", which decides whether a receiving surface counts as present, see Receiving surface presence;
- it marks the shell as not ready yet. The shell of the NETUP host shows a loading indicator instead of the menu and the module content until every mounted module has settled, and picks the page to open from the settled menu — otherwise the operator sees an intermediate menu and lands on a page that is not the first one.
Clear pending even when the data fails to load. It reports "the state is
not final yet", not "the request is in flight": a module that leaves the flag on
after an error keeps the shell in its loading state for good. Set it to false
as soon as the outcome is known, whichever it is — with a failed request the
module reports whatever pages it can offer.
A module that never reports the field is treated as settled from its first
report; degradation is cosmetic (capability 'moduleState.pending').
Module display name
title is the module name shown wherever the host labels the module itself —
the subtitle under same-named menu items of different modules, the group header
of a module that declares no domains, the settings-dialog tab, the module list
of the "Application information" view. Like page titles it is pre-localized by
the module and re-reported on locale change, which ModuleManifest.title
cannot be: the manifest is read before mount, and the host has none of the
module's translations.
Degradation is cosmetic (capability 'moduleState.title'): a host that ignores
the field falls back to ModuleManifest.title, so keep the manifest title a
readable name rather than an internal code. Report the field even when the two
match — that costs nothing and keeps the name in one place once it changes.
Settings and version info
hasSettings / hasFeatureFlags tell the host to build a settings-dialog
tab for the module; the tab content is rendered by the module through
EmbeddedHandle.mountSettings. mountVersionInfo feeds the host's
"Application information" view, which aggregates versions of the host and
every module.
The version node is a <tbody> of the table the host draws for the module's
section — the host's own rows (the module's contract version) live in a sibling
body of that same table, which is what keeps every value in one column. So
render table rows into it: a <tr> per version with a label cell and a value
cell, and no wrapper element around them. A module whose own DOM sits between
the node and its rows (its own <table>, a <div>, bare text) still shows the
values, but they line up on their own instead of with the section — set
display: contents on such wrappers to keep them out of the layout. Hosts
predating this layout pass a <div>; rows rendered for a <tbody> degrade the
same cosmetic way there.
Permissions editor fragment
A module that manages its own permission matrix reports
hasPermissions: true and implements
EmbeddedHandle.mountPermissions(node, role, context?) — the host embeds the
module's per-role permissions editor into its role-management page.
Degradation: an older host without the fragment slot simply shows its own
binary per-module access toggle.
interface PermissionsEditorContext {
ancestors: readonly string[];
}context is an observable of what the host knows about the edited role:
ancestors lists the roles it inherits from, expanded transitively, including
ancestors the operator selected in the host's role form but has not saved yet.
A module keys its matrix by role name and cannot resolve the host's role
inheritance on its own, so an inheriting role would otherwise show an empty
matrix while the host already grants it every ancestor permission.
It is observable rather than a plain value because the editor is live on both sides: the operator may pick another parent role while the module's own matrix holds unsaved edits, and re-mounting the fragment to deliver a new value would discard them. The host pushes the new value through the observable instead and re-mounts only on a role change.
Two rules make that work, and a host that breaks either takes back what the channel buys:
- The observable object stays the same for as long as the fragment is
mounted — a new value goes through
subscribe, not through a new object. Handing the fragment a fresh object per render re-subscribes it and drops the unsaved edits the channel exists to protect. get()returns a stable value while the ancestors have not changed. Expand the chain when pushing a new value, not inside the getter: a module that feedsget()into a cached-snapshot consumer (React'suseSyncExternalStoreand its equivalents) loops forever on a getter that builds a fresh array each call.
Three states are distinct. A missing context means the host predates the
argument — resolve the ancestors from the platform's own role tree instead. A
value of undefined means the host has not resolved them yet — the module
waits rather than drawing conclusions. An empty array means the role inherits
nothing.
Compatibility policy
CONTRACT_VERSION is the contract major and the only version number on
the wire:
- The host accepts a module only on exact major equality — checked twice:
from the docker label before loading code, and from
manifest.contractVersionafter. - The major is bumped only on breaking changes: changed semantics or type of an existing field, a removal, a new required field. A breaking bump requires host and modules to be rebuilt against the new major — by design a rare event.
- Additive extensions do not touch the version: a new field or method is
declared optional (
?) in the contract types, and the consuming side feature-detects at runtime. The plain-JS boundary makes the check trivial (hostApi.x === undefined).
Degradation directions:
- Host newer than module — optional
ModuleStatefields are absent, newEmbeddedHandlemethods are absent: the host gates on presence and, where the loss is functional, indicates "module requires an update". - Module newer than host — optional
HostApifields are absent: the module gates on presence, works degraded, and reports the contract paths of the features it actually needed inModuleState.missingHostFeatures. The host shows a degraded-state warning ("shell requires an update") with the list. Two kinds of absence stay out of that warning:- a cosmetic loss — the feature still works, only its presentation
differs (
shell.setAppBarIcons, see App-bar icon scale) — never reaches the degraded warning: a module that reports such a gap reports it throughcosmeticHostGaps, on the same grounds as the cosmetic misses of the report direction below; - an optimization channel — a field whose absence reproduces the
behaviour the module had before the field existed, costing only the saving
it enables (
visible, see Visibility channel) — is feature-detected but never reported at all: a degraded warning would tell the operator to update a host that lacks nothing they can see.
- a cosmetic loss — the feature still works, only its presentation
differs (
- Host omits an optional argument of a module method it does call
(
mountPermissions's context) — an optimization channel, likevisibleabove: the module keeps the behaviour it had before the argument existed, so nothing is reported. It cannot be reported in any case: both report channels carry contract paths ofHostApi/ModuleStatefields, and a missing argument of a module's own method is not such a path. The module tells "the host passed nothing" apart from an empty value, and falls back to its own source of the data. - Host → module data (new fields inside
licenseetc.) is not detectable; such extensions must be formulated so that ignoring them is harmless — otherwise the change is breaking.
State capabilities
The report direction ("module fills a new ModuleState field the host
ignores") cannot be detected by the module alone — setModuleState returns
nothing. The host therefore declares which post-major interpretation
abilities it implements in HostApi.capabilities (ids are contract field
paths, e.g. 'moduleState.pages.order'). The module-side check is
detectMissingStateCapabilities(state, hostApi.capabilities) against
STATE_CAPABILITY_REGISTRY:
- functional misses (the feature visibly stops working) go to
missingHostFeatures→ degraded indication; - cosmetic misses (ordering, placement, labelling) go to
cosmeticHostGapsand leave a console trace. They are deliberately kept out ofmissingHostFeatures: the degraded indication means "functionality is limited", and ordering losses would drown it in noise. A host readscosmeticHostGapsin its "Application information" view — the module works, only its presentation degrades, and updating the host restores it.
cosmeticHostGaps carries the cosmetic misses of the consume direction too —
an absent HostApi member whose loss is presentational only, reported by the
module itself without a host capability (there is nothing to declare: the
module sees the member missing). The id is the field path, as everywhere else:
'shell.setAppBarIcons'.
An absent capabilities field means the host predates the mechanism —
nothing is confirmed. HostCapability also carries host-provided-replacement
ids (e.g. 'unifiedPermissionsPage') that modules may use to hide duplicate
pages when the host provides the unified equivalent.
Both report channels are themselves additive fields, so their own absence
cannot be reported through them (that would recurse). They are therefore not
registry entries but meta-cases: when a module has something to report and the
host has not declared 'moduleState.missingHostFeatures' /
'moduleState.cosmeticHostGaps', the module logs the list to the console
once — support is unconfirmed, not necessarily missing.
Area contributions
The generic mechanism for one module (the donor) to contribute UI — a tab, a toolbar button, a dashboard block — into a page owned by another module (the receiver), across isolated roots. The host is a broker only: it never interprets the content.
- The donor declares contributions in
ModuleState.areaContributions(metadata only):id,targetPage,slot, pre-localizedtitle, optionalorder,visibleWhen,meta,requiredContext. - The receiver reads
hostApi.areas.getContributions(targetPage), decides where eachslotrenders, gives the broker an empty DOM node and a liveObservable<AreaContext>, and the broker routes the mount to the donor'sEmbeddedHandle.mountArea(id, node, context). The donor's content stays in the donor's component tree (portal), and context changes stream through the observable without re-mounting. visibleWhenis a declarative matcher evaluated by the receiver withmatchesAreaContext: for every declared key the live context value must be in the allowed list (multiple keys AND, missing matcher → always visible, missing context key → hidden).slotnames, theAreaContextshape andmetakeys are a pair-convention between the two modules — the contract and the broker do not interpret them. The receiver owns the convention.- Degradation is soft in every direction: no broker (
hostApi.areasabsent), no donors, or an older receiver simply mean the contribution does not appear.
Area pair compatibility
Pair conventions are strings, so a version skew between donor and receiver (a renamed slot, a dropped context key) would otherwise fail silently. Both sides therefore declare their half of the convention:
- the receiver declares its accepting surface in
ModuleState.areaTargets(page→slots, each slot optionally listing thecontextKeysandmetaKeysit supports); - the donor declares the context keys its component actually reads in
AreaContribution.requiredContext(visibleWhen/metakeys are derived from the declaration itself).
detectAreaPairMismatches(states) — a pure function over (moduleId,
ModuleState) pairs — reports unknown-slot, missing-context and
missing-meta mismatches. Contributions to pages nobody declares are
skipped (the receiver may be absent or predate the mechanism), and an
undefined key list skips that aspect. The host runs it over live module
states and shows mismatches as a degraded state; module authors are
encouraged to run the same checker in CI over both sides' declarations.
Pair evolution rules: additivity first (new slots and keys are free; a donor must survive missing keys); when adding, release the receiver's declaration first; a rename or removal is breaking for the pair and must ship in lockstep on both sides — in the deployment window the checker will (by design) show the mismatch.
Receiving surface presence
A donor whose own page duplicates the receiver's page (its content now arrives as a contribution) hides that page from the unified menu — but only while the replacement actually exists in this shell. The receiver's page may be absent (the module is not installed at all) or hidden for this user (a feature flag, a missing permission), and hiding the duplicate then leaves the shell without the functionality.
- The receiver drops an
areaTargetsentry whenever the page hosting the surface is not among the reportedpages— a declared target therefore means "this surface is here and reachable". - The host aggregates the declarations and answers
hostApi.areas.getTargetPages()with{ pages, settled }; the donor hides its duplicate page when thetargetPageid is inpages. settledisfalsewhile a mounted module has not reported yet or reportsModuleState.pending: true(see Preliminary reports). A donor treats an unsettled snapshot as "replacement present" and keeps the duplicate hidden, so pages do not flash in and out while the shell fills up. Bound the wait: a module that never settles must not hide the donor's pages forever.- Degradation: an older host has no
getTargetPages, so the donor hides nothing and the duplicate stays visible — cosmetic, never a loss of functionality.
Contribution unsaved changes
A contribution renders inside the receiver's tree, so the receiver's own
navigation — its tabs, its inner links — takes the contribution down with
whatever is unsaved in it. The donor's guard cannot help: setPreventUnload
reports to the host, and the receiver is a different module with its own state.
- The donor keeps reporting its reasons through
shell.setPreventUnloadas usual; nothing changes on the donor side. - The host answers
hostApi.areas.getContributionReasons(targetPage)with the reasons of the donors whose contributions are currently mounted on that page. A declared but unrendered contribution reports nothing. ShellPreventReason.idis unique within its own module, so the host makes the ids unique across the page before handing them over — prefix them with the donor id (ds:unsaved-form). A receiver lists the reasons side by side with its own and needs a stable key for each.- Changes are announced through the broker's
subscribe, the same listener that already tracks declarations. - The receiver treats these reasons as read-only: it asks for confirmation before navigating away, but it must not clear them — they belong to the donor and go away when the contribution unmounts.
- The host applies the same reasons to leaving the shell, so the drawer,
the breadcrumbs and
beforeunloadare guarded without the receiver doing anything. - Degradation: an older host has no
getContributionReasons, so the receiver navigates without asking — the behaviour it had before the channel existed. The loss is functional, not cosmetic, so a receiver that queries the channel reports it inmissingHostFeatures(see Compatibility policy) instead of failing silently.
Receiving surfaces of the NETUP modules
A custom module can contribute into the pages below. overview is owned by the
host itself, the rest by the mw module (IPTV Middleware), which is present in
every installation. Declare targetPage and slot exactly as listed, plus the
context keys your component reads in requiredContext.
| targetPage | Page | slot | Context keys | meta keys |
| ------------------ | ----------------------- | ---------------------- | ------------------------------------------------------------------- | --------------- |
| accounts-devices | Accounts & Devices | root-tab | — | — |
| | | root-tab-toolbar | activeRootTab | — |
| | | account-tab | accessCard, deviceId, accountMode | deviceCentric |
| | | status-section-main | accessCard, deviceId, connectionId, isOnline, accountMode | — |
| | | status-section-aside | same as status-section-main | — |
| overview | Overview (landing page) | block | — (none) | — |
| statistics | Statistics | root-tab | — | — |
| system-settings | System settings | root-tab | — | tabIcon |
What the slots mean:
root-tab— a top-level tab of the page, rendered after the native ones;root-tab-toolbar(Accounts & Devices only) puts a control into the tab strip and is normally bound to its own tab withvisibleWhen: { activeRootTab: ['<your-root-tab-id>'] }.account-tab— a tab of the account detail view;status-section-mainandstatus-section-asideare sections of its «Status» tab (main column and the column next to the screenshot).block— a card on the overview page, the landing page of the shell. The host draws its own server indicators atorder1, 2, 70 and 80, and the NETUP modules contribute blocks at 10…60 on the same scale, so pick yourorderto place the block between them. The page hands out no context: a block fetches its own data and gates itself by its own permissions.
Context values worth knowing: accountMode is 'tv' | 'ds' | 'hybrid', so a
contribution meaningful only for signage declares visibleWhen: { accountMode:
['ds', 'hybrid'] }.
meta keys are plain data read by the receiver: tabIcon takes SVG markup
for the tab icon (a tab without it simply gets no icon), and deviceCentric
(boolean) tells the receiver whether picking a device changes the tab content,
which decides if the device sidebar is shown; omitting it means «not device
centric».
Every key is optional on both sides, and the surface only grows: an unfilled
slot or an unread context key is never an error. Run
detectAreaPairMismatches in your CI against the declarations above to catch
a skew before deployment.
Examples
Minimal module (no framework)
// src/manifest.ts
import { CONTRACT_VERSION, EmbeddedHandle, ModuleManifest } from '@netup/unified-admin-contract';
const manifest: ModuleManifest = {
id: 'acme',
contractVersion: CONTRACT_VERSION,
title: 'ACME',
mount(container, hostApi): EmbeddedHandle {
const root = document.createElement('div');
container.appendChild(root);
let page = '/';
const render = () => {
root.textContent = `ACME ${page} — theme: $