CMS System Overview (LLM Entry Point)
This page is the entry point for understanding the Media-iBox CMS as a whole system. It is written so an LLM (or a new engineer) can read this single document, build an accurate mental model of every moving part, and then jump into the deeper per-area docs with context.
If you only need a specific area, see Glossary and See also at the bottom and follow the link. If you want the original high-level overview, see cms/about.md — this document is intentionally several layers deeper than that.
Scope. This is the "how the system actually behaves at runtime" doc, not a how-to-install or how-to-customise doc. Install / customisation docs are linked inline where relevant.
Audience and how to read this
Two distinct audiences are served:
- LLM agent. Read top-to-bottom once and the model has enough context to reason about any subsystem without further reads. Every cross-link is to a doc inside this repo or to a code path with an exact file location.
- New engineer. Read top-to-bottom once for orientation, then bookmark the See also section and treat this as an index.
Reading order:
What the CMS actually is
↓
Repository map (which repo does what)
↓
RenderV2 end-to-end (the heart of the system)
↓
React shell + federation (how the UI is composed)
↓
Backend overlay (how a deploy works)
↓
AuthN + AuthZ (permissions, OAuth, cookies, 2FA)
↓
Each microservice in turn
↓
Caching, database conventions, environments
↓
Observability + glossary
What "the CMS" actually is
A pod-deployed three-tier CMS:
- React frontend (the shell) — the SPA the operator sees. Static JS/CSS served by nginx. Loads federated component bundles at runtime so each customer can ship their own widgets without rebuilding the shell.
- FrontEnd Server (the BFF) — ASP.NET Core 8 Backend-for-Frontend.
Aggregates pages, talks to the Data API and the microservices, and emits
a single
{schema, data}response per page-render. Hosts every C# customisation as compiled DLLs overlaid into a base image. - Data API + microservices — the source of truth. The Data API is the model-agnostic, metadata-dependent REST API ("MibApi"). The microservices (Authorization, Permission, Concurrency, EditHistory, FileManagement, ContentCriteria) each own one cross-cutting responsibility.
See cms/about.md for the original Media-iBox container diagram (rendered as Mermaid C4Dynamic). The diagram below is the same system re-drawn around the BFF's role in a render request.
flowchart LR
subgraph Browser
SPA["React shell<br/>(MibFrontEnd · CMS app)"]
end
subgraph FrontEnd_Server["FrontEnd Server pod (MibServer3 + overlays)"]
BFF[".NET 8 BFF<br/>DisplayWorkflow.GetRenderV2"]
CUST["Customer customisation DLLs<br/>(any number of overlays)"]
end
subgraph Remotes["Federated React remotes (nginx)"]
R1["customComponents<br/>(customer remote)"]
R2["customComponents2 / 3 ..."]
end
SPA -- "/api/v2/display/<page>/<id>" --> BFF
SPA -- "GET remoteEntry.js" --> R1
SPA -- "GET remoteEntry.js" --> R2
BFF --> CUST
BFF --> DataAPI["MibApi (Data API)"]
BFF --> AuthMS["MibAuthorization<br/>(OAuth + users)"]
BFF --> PermMS["Permission MS"]
BFF --> ConcMS["Concurrency MS"]
BFF --> EditMS["EditHistory MS"]
BFF --> FileMS["FileManagement MS"]
DataAPI --> CritMS["ContentCriteria MS"]
AuthMS --> PermMS
style BFF fill:#dde,stroke:#557
style SPA fill:#ded,stroke:#575
What is "behind" the BFF
The BFF does not own data. Everything it returns comes from a downstream:
- Item / list / form data → Data API (MibApi)
- Permission decisions → Permission MS (cached in BFF per-user)
- Edit history reads → EditHistory MS (writes go via the Data API)
- Concurrency indicators → Concurrency MS (per-page poll)
- Content criteria evaluation → ContentCriteria MS (called by the Data API, not directly by the BFF)
- File uploads / signed download URLs → FileManagement MS
- OAuth introspection + UserInformation → Authorization MS
The BFF's value-add is orchestration: parallelising the per-component fetches, applying the customer's customisations, and shaping the response so the React shell can render with no further hops.
Repository map
A typical engineering setup involves five categories of repo. The first three are MIB framework repos and are the same across every project; the remaining two represent a per-customer customisation — every customer has their own peers of these two, and a single deployment can layer several customisations on top of one MIB base. There are potentially thousands of customisations in the wild; this doc is framework-side and treats them generically.
| Repo category | Example local path | What it ships |
|---|---|---|
| MibServer3 (framework) | ~/Documents/agilecontent/MibServer3 |
The BFF. Compiles to MediaiBox.Cms.FrontEnd.*.dll. Owns DisplayWorkflow.GetRenderV2 and every API endpoint the React shell hits. |
| MibFrontEnd (framework) | ~/Documents/agilecontent/MibFrontEnd |
React shell turborepo: apps/cms (host), apps/auth (standalone SPA), packages/* (api-connector, core-components, mib-modules, ui, mock-server). |
| MibDocs (framework, this repo) | ~/Documents/agilecontent/MibDocs |
DocFX site published to http://docs.agilesvcs.com/mediaibox/. |
| Customer customisation (C#) | varies per customer | A solution that compiles to <Customer>.Mib3.*.dll and similar customer-namespaced assemblies. Often contains hundreds of components, business rules, plugins, page templates. |
| Customer federated remote (React) | varies per customer | A Vite app that exposes ./all → remoteEntry.js with the customer's custom React widgets. A customer can publish multiple remotes and wire them into the shell's three slots. |
The published documentation portal at docs.agilesvcs.com/mediaibox/ is just
this repo rendered by DocFX — no separate content lives there.
There are also supporting repos that publish NuGet packages consumed by the five above:
- MediaiBox.Core.* — foundational libraries (Config, Caching, Logging,
Reflection, etc.). Published to
https://nuget.agilesvcs.com. - MediaiBox.Cms.Api.* — Data API server code and client SDK.
- MediaiBox.Cms.MicroServices.* — model + client SDK for each MS (Permission, EditHistory, FileManagement, Concurrency, ContentCriteria).
- MediaiBox.Cms.Authorization.* — Authorization Server code and client SDK.
You will not typically check those out unless you need to step through their
source. They appear in MibServer3's csproj files as <PackageReference> and
are overlaid as DLLs during deploy (see Backend overlay system).
The RenderV2 request, end-to-end
This is the heart of the system. Walk through one request from click to rendered page. Every assertion below is grounded in source — file paths are given inline.
Request
GET https://<env>.mib-frontend.example.com/api/v2/display/articles_edit/12345?previousPageKey=articles_list
Cookie: .AspNetCore.Cookies=<oauth-issued cookie>
x-request-id: <correlation id>
Throughout this document, articles_edit and the articles_* family are
generic placeholder pageKeys. Real customers ship their own page keys
following their own domain (movies_*, programmes_*, assets_*,
stories_*, …) — the framework imposes no naming convention.
The React shell (apps/cms in MibFrontEnd) issues this via the
@agilecontent/mib-api-connector package
(packages/api-connector/src/services/page/index.ts, function getRender,
around line 44). The cookie is browser-included by the same-site policy; the
request id is generated client-side per request for log correlation and is
threaded through BeginScope("requestId: {requestId}") on the server.
Response shape
{
"pageKey": "articles_edit",
"title": "...",
"breadcrumbs": [...],
"configuration": { "save": true, "create": true, ... },
"schema": {
"components": [
{ "title": "...", "key": "articles_template_001", "type": "list", "schema": { ... } },
{ "title": "...", "key": "articles_template_002", "type": "form", "schema": { ... } },
{ "title": "...", "key": "articles_template_003", "type": "my_custom_widget", "schema": { ... } }
// ... only successful + non-hidden components survive into this array
]
},
"data": {
"components": [
{ "key": "articles_template_001", "data": { ... } },
{ "key": "articles_template_003", "data": null }
// ... data-null is legal for components that fetch their own data after mount
]
}
}
Two parallel arrays. schema.components describes what to render (column
definitions, field types, configuration); data.components carries what
values to render. Components are paired by key. See
6.0/ReactComponents.md for the per-component-type
contracts.
Server-side flow inside the BFF
DisplayWorkflow.GetRenderV2 (MibServer3:MediaiBox.Cms.FrontEnd.Workflow.Mvc/Display/DisplayWorkflow.cs)
is the heart. The entry point is at line 97. Approximate phases:
sequenceDiagram
participant Shell as React shell
participant Mw as MibTokenRefreshMw
participant Ctrl as DisplayController
participant DW as DisplayWorkflow
participant Eval as ComponentEvaluator
participant Repo as Repositories<br/>(Meta, Item, ...)
participant Api as Data API (MibApi)
participant Auth as Authorization MS
participant Perm as Permission MS
Shell->>Mw: GET /api/v2/display/<page>/<id>
Mw->>Mw: read expires_at from auth props
alt token expires < 10 min
Mw->>Auth: oauth/token<br/>(grant_type=refresh_token)
Auth-->>Mw: new access_token + refresh_token
Mw->>Mw: re-sign-in (write new cookie)
end
Mw->>Ctrl: forward request
Ctrl->>DW: GetRenderV2(request)
DW->>Auth: validate cookie / get UserInformation
Auth-->>DW: UserInformation<br/>(mediatype+source permissions)
DW->>Repo: GetRenderAndComponentInstances(request)
Repo->>Api: batch fetch META_<mediaType> + adm_fields + adm_relateds + ...
Api-->>Repo: MultipartResponse
Repo->>Auth: GetUser() for cache key user-scoping (where required)
Repo-->>DW: viewData.Components[] + componentInstances{templateKey → instance}
par Per-component parallel (Task.WhenAll)
DW->>Eval: EvaluateAsync(component, request, instances)
Eval->>Eval: ShouldHideComponent? → SkippedHidden
Eval->>Repo: per-component data fetch (Item, RelatedList, ...)
Eval->>Eval: instance.MapSchema(viewData)
Eval->>Eval: instance.MapData(viewData)
Eval-->>DW: ComponentEvaluation { outcome, schema?, data? }
end
DW->>DW: merge into displaySchema + displayData<br/>(loop iterates in submission order, omits Errored/SkippedHidden)
DW->>DW: SortComponents()<br/>OrderBy(Order).ThenBy(TemplateComponentKey)
DW->>DW: LogRenderV2Stats(...)<br/>emits [RenderV2-Stats] {json}
DW-->>Ctrl: DisplayViewDataV2 { schema, data, ... }
Ctrl-->>Shell: JSON
The five phases inside GetRenderV2
The GetRenderV2 method is roughly 100 lines once you account for logging
and helper calls. Phases:
Phase 1 — Request scope and metadata fetch. A BeginScope("pageKey: {pageKey}", request.PageKey)
opens at the top of the method, so every log line for the rest of the request
carries the page key. MetaRepository.GetRenderAndComponentInstances then
fetches the rendered page from the FrontEnd DB (PAGE + PAGE_COMPONENT
tables) and resolves each TemplateComponent into a concrete C# instance via
ComponentFactory. The mediatype metadata is fetched from the Data API as one
multipart response.
Phase 2 — Authentication snapshot. IUserRepository.GetUser() resolves
the OAuth cookie via MibAuthorizationClient and returns UserInformation
including MediaTypePermissions, SourcePermissions, and TokenHash. The
token hash is the only legitimate per-user cache key in the BFF (used
exclusively for the source-tree cache); everything else is pod-shared.
Phase 3 — Parallel component evaluation. Each component runs through
ComponentEvaluator.EvaluateAsync in parallel via Task.WhenAll. The
evaluator:
- Checks
IHideableComponent.ShouldHideComponent(if implemented). True → outcome =SkippedHidden, component is dropped from the response. - Calls
MapSchema(viewData)viaMibReflection.GetMethodResultAsync. The reflection step is what lets old-style components and new-styleIRenderableComponentV2<TConfig, TData, TSchema>share one dispatch path. - If schema came back non-null, calls
MapData(viewData)similarly. - Catches any thrown exception, logs
[Render-ComponentFailure] {json}, marks outcome =Errored. - Returns a
ComponentEvaluationstruct.
The resilience gate is:
// MediaiBox.Cms.FrontEnd.Workflow.Mvc/Display/ComponentEvaluator.cs (~line 162)
if (!schemaResultOutcome.IsSuccess)
{
return ComponentEvaluation.Errored(...);
}
// data-null is intentionally NOT an error — many components (EpgReview,
// MovieValidation, anything async) return null data on first paint and
// fetch their own data over AJAX after mount.
This was PR #1874 —
before it, both schema-null and data-null produced Errored, which broke
any async-loaded widget whose React side fetches its own data after mount.
Phase 4 — Result aggregation. The submission-ordered evaluatedComponents
array is iterated sequentially. Errored and SkippedHidden outcomes are
skipped; everything else gets its schema appended to displaySchema.Components
and its data to displayData.Components. The iteration is sequential, but
since the heavy lifting already ran in parallel in Phase 3, this is cheap.
Phase 5 — Order and stats. Two final transforms:
// SortComponents — stable order with deterministic tie-break
displaySchema.Components = displaySchema.SortComponents();
// Internally:
return components
.OrderBy(c => c.Order)
.ThenBy(c => c.TemplateComponentKey, StringComparer.Ordinal)
.ToList();
Before the .ThenBy, equal Order values resolved to insertion order, which
differed between environments because the parallel fan-out's Task.WhenAll
completion order varied with database latency. Customers reported components
"swapping positions" between environments — that's the bug
.ThenBy(TemplateComponentKey, StringComparer.Ordinal) fixes. Ordinal
comparison ensures the result is locale-independent.
Finally, LogRenderV2Stats(request.PageKey, evaluatedComponents, renderEvalSw.ElapsedMilliseconds)
emits a single [RenderV2-Stats] JSON line — see Observability
for the schema.
Key invariants to remember
- Parallelism. Per-component
MapSchema/MapDatacalls fan out viaTask.WhenAll(componentEvalTasks). The result array preserves submission order, which isviewData.Componentsorder, which is template-walk order. - Resilience. A single broken widget cannot take down the page.
Erroredcomponents are dropped; the response is partial-rendered rather than failed. The React shell renders what it received and surfaces a console warning for missing components. - Ordering.
SortComponentsis stableOrderBy(Order)+ThenBy(TemplateComponentKey, StringComparer.Ordinal). The primary key is theOrderfield onDisplayComponentViewData, which is overridden byDisplayComponentPreferencesWorkflowwhen the user has stored tab preferences. Ties resolve alphabetically byTemplateComponentKey. - Per-render telemetry. A single
[RenderV2-Stats]JSON line is emitted per render with per-componentschemaMs/dataMs/outcome. Failures get a paired[Render-ComponentFailure]line. See the RenderV2 telemetry reference in the MibServer3 repo for the full schema.
Per-component evaluation (drill-in)
Each component is a C# class — either a stock MibServer3 component
(MediaiBox.Cms.FrontEnd.Server.Component.*) or a customisation assembly
shipped by the customer (e.g. <Customer>.Mib3.*) — that implements one of:
public interface IRenderableComponentV2<TConfiguration, TData, TSchema>
: IComponent<TConfiguration> where TConfiguration : IComponentConfiguration
{
Task<TData> MapData (object viewData, CancellationToken cancellationToken);
Task<TSchema> MapSchema(object viewData, CancellationToken cancellationToken);
}
// Optional, gates rendering on a per-request boolean.
public interface IHideableComponent
{
Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken cancellationToken);
}
// Optional, lets the React side refresh the component without a full page reload.
public interface IRefreshableComponentV2<TConfiguration, TViewData> : ...
ComponentEvaluator.CreateDisplayInstance invokes MapSchema and MapData
through MibReflection.GetMethodResultAsync so plain MibComponent and the new
V2 components dispatch through the same code path. See
6.0/ReactComponents.md for the per-type
schema/data examples.
The five outcome states
ComponentEvaluation.Outcome is an enum with five values, every one of which
shows up in [RenderV2-Stats]:
| Outcome | Meaning | Effect on response |
|---|---|---|
Success |
MapSchema returned non-null and MapData did not throw. |
Schema + data appear in response. |
SuccessDataNull |
Schema is non-null; data is null. Either the component is async-load (fetches after mount) or there is no row to display yet. | Schema appears, data is null. React tolerates this. |
SkippedHidden |
IHideableComponent.ShouldHideComponent returned true (e.g. no write permission). |
Component is omitted from both arrays. |
Errored |
MapSchema returned null OR any uncaught exception during evaluation. |
Component is omitted. A [Render-ComponentFailure] line is emitted. |
SkippedNoMapping |
A misconfiguration — the template references a TemplateComponentKey that no IComponent registration claims. |
Component is omitted. Diagnostic logged. |
What MapSchema and MapData actually do
Take the Form component as the canonical example
(MediaiBox.Cms.FrontEnd.Server/Component/Form/FormComponentBase.cs):
MapSchema(viewData)projects theMediaTypeInformation(which the Meta repository already loaded) into a JSON-friendly shape: list of fields (name,type,label,required,defaultValue,enumValues,relatedMediaType, …), tab structure, edit-flags (canCreate/canWrite/canDeletederived from the user's permissions), related-mediatype hints, validation rules.MapData(viewData)fetches the actual item viaIItemRepository.GetItem(mediaType, id)and projects field values into a{ fieldName: value }dictionary. ForForm, it also callsIRelatedRepository.GetDirectRelatedsto fill in related-record IDs.
The output of MapSchema is roughly stable per mediatype (changes only
when the operator edits the data model); the output of MapData changes per
item. This is why the BFF caches MediaTypeInformation aggressively (see
Caching strategy) but never caches MapData results.
The React shell (MibFrontEnd)
A turborepo with npm workspaces.
Layout
| Workspace | Role |
|---|---|
apps/cms |
The host. Vite app; main.tsx mounts <App/> into #root. This is what the user actually loads. |
apps/auth |
Standalone login / MFA / password-recovery SPA. Not a federated module. Built and deployed independently. |
apps/docs |
Documentation (not in the workspace graph). |
packages/api-connector |
@agilecontent/mib-api-connector — axios-based HTTP client, services per BFF endpoint (page, components, user, menu, …). |
packages/core-components |
Built-in React component registry that maps schema[i].type strings (list, form, relatedList, …) to React components. |
packages/mib-modules |
Higher-level stateful modules: ContentEdition, GlobalActions, Filters, Sorter, i18n. |
packages/ui |
@agilecontent/ui — base styled components (buttons, inputs, modals). |
packages/mock-server |
MSW + vitest setup for offline development. |
Module Federation
apps/cms is a Vite Module Federation host using
@originjs/vite-plugin-federation. The host declares three remotes:
// apps/cms/vite.config.ts:35–51
federation({
name: 'cms',
remotes: {
customComponents: { external: VITE_CUSTOM_COMPONENTS_URL, format: 'esm' },
customComponents2: { external: VITE_CUSTOM_COMPONENTS_2_URL, format: 'esm' },
customComponents3: { external: VITE_CUSTOM_COMPONENTS_3_URL, format: 'esm' },
},
shared: ['react', 'react-dom', '@agilecontent/ui', /* ... */],
})
Remote URLs resolve at runtime from config.json or env, so the same shell
image works for any customer just by pointing it at a different remote. The
shared declaration means React and @agilecontent/ui are loaded once and
de-duplicated across remotes — without this, every remote would carry its own
React copy and the hooks rules would silently break across federation
boundaries.
Dispatch from JSON to React
apps/cms/src/common/helpers/components.ts is the dispatch table:
buildComponents(schema.components):
for each c in schema.components:
if isCoreComponent(c.type) → packages/core-components registry
else → customComponents/all (federated remote)
The core registry covers the stock types (list, form, relatedList,
asyncRelatedList, epg, contentcriteria, dmm, permissionList, …).
Anything else is resolved from one of the federated remotes' ./all export
(which yields { components: { [type]: Component } }). Lookup is in declared
order — customComponents first, then 2, then 3.
Bootstrapping the shell
index.html ─ vite-injected ─ assets/index-XXX.js
│
├── React, ReactDOM, @agilecontent/ui (shared)
├── core-components registry (built-in types)
├── ContentEdition module (state, save, dirty flag)
└── runtime fetch:
./config.json → API base URL, remote URLs
/api/user/me → who am I, my permissions
/api/menu → left nav
customComponents/remoteEntry.js (lazy on first use)
customComponents2/remoteEntry.js
customComponents3/remoteEntry.js
The shell does not pre-fetch a page — it waits for the operator to click an
item, then issues /api/v2/display/<pageKey>/<id> and renders the response
top-down.
Auth on the shell side
apps/cms does not implement OAuth flows itself — it relies on the BFF's
cookie. On a 401/403 from any API call, the shell redirects to apps/auth's
login page, which talks to the Authorization Server's OAuth endpoints
directly and ultimately writes the .AspNetCore.Cookies cookie that the BFF
trusts. See Authentication and Authorization
for the full handshake.
The federated remotes
A federated remote is a self-contained Vite app + Docker image. Each customer publishes their own — the shape below is the contract every remote must satisfy, regardless of customer name.
Shape
Bundler: Vite 6.3.5 +
@originjs/vite-plugin-federation.One exposed entry:
./all → ./src/main.tsx, shipped asassets/remoteEntry.js.Shape of the entry: an object the host merges into its component registry. Each entry maps a
typestring (matching the BFF'sschema[i].type) to a React component:// approximate shape of `customComponents/all` export default { components: { "my-custom-list": MyCustomListWrapper, "my-custom-form": MyCustomFormWrapper, "domain-specific-x": DomainSpecificXWrapper, // ... typically dozens of entries per customer remote }, decorators: { ... }, handlers: { ... }, translations: { ... }, };
Props contract
MibComponentProps<S, D> (defined in each customer remote, typically at
src/common/types/index.ts):
type MibComponentProps<S, D> = {
componentKey: string;
title?: string;
schema: S; // ← BFF's schema.components[i].schema
data: D; // ← BFF's data.components[i].data
pageKey: string;
pageIds?: string;
isBulkEdit?: boolean;
setDirty?: (dirty: boolean) => void;
};
Most remote components ignore schema and use data only. The schema is
still emitted because some component types (lists, forms) need column/field
metadata to render properly.
Runtime deployment
Multi-stage Docker build (Node 22 alpine → nginx 1.21 alpine). nginx serves
/assets/remoteEntry.js with no-cache headers and the rest of the bundle as
7-day-immutable static files. The host fetches
http://<remote-service>:4000/assets/remoteEntry.js at bootstrap.
Storybook (in .storybook/) is the dev environment — components are
developed in isolation with *.stories.tsx, then re-exported via federation.
"Many overlays" — the model
Each customer has at least one remote. A single shell deployment can wire up
to three remotes simultaneously (customComponents, customComponents2,
customComponents3) — same shape, different type → Component registrations.
The host's dispatch resolves a type by searching all configured registries
in order, so a deployment that composes multiple customisations resolves
overrides deterministically: the higher-priority slot wins.
Why three slots?
Three is a soft cap, not a hard one. The pattern lets a single deployment ship:
customComponents— the customer's core widgets.customComponents2— a subsidiary brand's widgets, or a feature-flag bundle that ships ahead of the core.customComponents3— A/B testing slot, occasional one-off.
If a fourth becomes necessary, both vite.config.ts and the host dispatcher
need a new entry — straightforward but coordinated change.
Backend overlay system
A FrontEnd Server pod's /app/ directory is built from four layers of
DLLs at deploy time. Understanding this layering explains every deploy script
in the org.
| Layer | Origin | Examples | Volatility |
|---|---|---|---|
| 1. Base image | CI-built per-customer image, e.g. <customer-registry>/mib/frontend:<version-tag> |
Bootstrap.config, wwwroot/, config/Dictionary/, all DLLs from a known good build |
Slow — tied to released MibServer3 + customisation versions |
| 2. MibClient2 / NuGet deps | Published packages from https://nuget.agilesvcs.com (MediaiBox.Core.*, MediaiBox.Cms.Api.*, MediaiBox.Cms.MicroServices.*, MediaiBox.Cms.Authorization.*, MediaiBox.Storage.*, BeIT.Memcached) |
MediaiBox.Core.Config.dll, MediaiBox.Cms.MicroServices.Permission.Model.dll, … |
Medium — pinned per release |
| 3. MibServer3 own DLLs | This repo's csprojs | MediaiBox.Cms.FrontEnd.Server.dll, MediaiBox.Cms.FrontEnd.Dao.dll, MediaiBox.Cms.FrontEnd.Workflow.Mvc.dll |
High — your dev work touches these |
| 4. Customer customisations | One or more customer customisation repos, one per customer/product | <Customer>.Mib3.*.dll (often 100+ per customisation), plus any customer-namespaced peer assemblies |
High — customer-specific |
Why overlays exist
The "base image" is enormous (a customer's customisation alone is typically
hundreds of MB, plus React assets and config). A fresh dotnet publish of
MibServer3 alone wouldn't include the customisation, and the pod would crash
on startup looking for Bootstrap.config. So local builds overlay only
what they own on top of the customer-stable base image:
FROM <customer-base-image>:<tag>
COPY *.dll /app/ # locally-rebuilt MibServer3 + customer customisation + NuGet deps
The Bootstrap.config file at /app/Bootstrap.config is what tells MibServer3
where the config/ folder is and which assemblies to scan for components —
that's why a pure dotnet publish crashes (it doesn't reproduce that file).
Deploy pipeline
The shape of a deploy is the same across customers; only the customisation solution name and the customer namespace differ. Five phases:
dotnet build MibServer3.sln -c Release— produces MibServer3 own DLLs and copies every transitive NuGet dependency into the bin folder.dotnet build <Customer>.Mib3.sln -c Release— produces every<Customer>.Mib3.*.dllfor that customer (often 100+ DLLs).- Stage overlay. Glob
MediaiBox.*.dll+BeIT.*.dll+<Customer>.Mib3.*.dllfrom the bin folders; filter against the base image's/app/*.dllso separate-pod DLLs (Agent.EditHistoryTsvExporter, TsvExporter, Tests) don't sneak in. docker buildx build --platform linux/amd64+docker push.- Delete the running pod via the cluster API (Rancher in many setups);
imagePullPolicy: Alwaysre-pulls the floating:mib-frontend-latesttag.
A typical overlay is around 200 DLLs.
Version pinning matters
DLLs reference each other by AssemblyVersion. If MibServer3 is compiled
against the 7.0.0-beta4 NuGet feed of MediaiBox.Core.*, the resulting
Server.dll manifest says "I need MediaiBox.Core.Config, Version=7.0.0.0".
If the base image only has the 6.0.96.0 copy, the pod crashes at startup
with FileNotFoundException. That's why the deploy script overlays the entire
MediaiBox.* set, not only MibServer3's own DLLs.
Why we glob MediaiBox.* and not narrower
A first attempt at the deploy script globbed only
MediaiBox.Cms.FrontEnd.* + MediaiBox.Core.*. The pod still crashed —
this time on MediaiBox.Cms.MicroServices.Permission.Model 7.0.0.0 not found.
That assembly is referenced indirectly through the Permission MS client
SDK, which is itself a 7.0.0-beta4 package, but doesn't live under
MediaiBox.Core.* or MediaiBox.Cms.FrontEnd.*. The fix was to broaden the
glob to MediaiBox.* and rely on the filter step (#3 above) to drop the
non-FrontEnd-pod DLLs.
Removing MibClient2
Earlier 6.0 builds used a separate phase to compile MibClient2.sln and copy
its DLLs in. As of 7.0, those DLLs are published as NuGet packages under
MediaiBox.Core.* and pulled in transitively through MibServer3's csprojs.
The deploy script no longer needs a MibClient2 build phase. Locally, this is
just a csproj PackageReference bump from a version like 6.0.96 to
7.0.0-beta4 — never commit this change, it's a local-only ergonomic.
Authentication and Authorization
Two distinct concerns
| Concern | Service that owns it | Per-request artefact |
|---|---|---|
| AuthN ("who are you") | Authorization Server (MibAuthorization) | OAuth access_token, encrypted as .AspNetCore.Cookies |
| AuthZ ("what may you do") | Permission Microservice | MediaTypePermissions, SourcePermissions on UserInformation |
The BFF carries both, but owns neither. Local AuthN code in the BFF is just a thin wrapper that introspects the cookie and refreshes the token; local AuthZ code is just a consumer of the Permission MS results.
OAuth — Authorization Code grant
MibServer3 uses the Authorization Code grant type (OAUTH_CLIENT_TYPE = 1
in the API_CLIENTS table). This means the user's credentials are never
visible to the BFF — the user authenticates directly with the Authorization
Server, which then issues an authorization code that the BFF exchanges for
tokens server-side.
sequenceDiagram
title Authorization Code Flow
User->>MibServer3: Request authorization
MibServer3-->>+MibAuthorizationServer: oauth/authorize<br/>Client Id, Client Secret, Redirect URI
MibAuthorizationServer-->>User: Request consent for new client
User-->>MibAuthorizationServer: Consent granted
MibAuthorizationServer-->>-MibServer3: Authorization Code
MibServer3->>+MibAuthorizationServer: oauth/token<br/>Authorization Code, Redirect URI
MibAuthorizationServer-->>-MibServer3: Access token, Refresh token
Once oauth/token returns the access + refresh token, MibServer3 calls
HttpContext.SignInAsync to write the encrypted .AspNetCore.Cookies cookie
containing both tokens and the expires_at timestamp. From that point on, the
browser includes the cookie automatically and the BFF can de-serialise it
without further round-trips to the Auth Server — except to refresh the token,
which is what the middleware handles.
Other grant types
The same Authorization Server also issues tokens under the Resource Owner Password grant (OAUTH_CLIENT_TYPE = 3), used by:
- MibServerApi (the Data API) for its own service-to-service calls
- FileManagement microservice for token introspection
- The Authorization Server itself for its admin pages
You will not normally configure these — they ship with the base migrations.
See cms/auth_server/AuthorizationServerConfiguration.md
for the column-level detail of the API_CLIENTS table.
Token refresh — MibTokenRefreshMiddleware
Tokens expire (typically 60 minutes — see accessTokenMinutes in
MibAuthorizationServerConfig). A request that arrives less than 10 minutes
before expiry triggers an automatic refresh, transparent to the user.
// MediaiBox.Cms.FrontEnd.Server/Authentication/MibTokenRefreshMiddleware.cs
// (paraphrased)
public async Task InvokeAsync(HttpContext context, IAuthService authService)
{
var authResult = await context.AuthenticateAsync();
if (!authResult.Succeeded) { await _next(context); return; }
var expiresAtUtc = authResult.Properties.GetTokenValue("expires_at");
if (expiresAtUtc != null && DateTimeOffset.Parse(expiresAtUtc) - DateTimeOffset.UtcNow < TimeSpan.FromMinutes(10))
{
var refreshToken = await context.GetTokenAsync("refresh_token");
var refreshed = await authService.RefreshTokenAsync(refreshToken);
authResult.Properties.UpdateTokenValue("access_token", refreshed.AccessToken);
authResult.Properties.UpdateTokenValue("refresh_token", refreshed.RefreshToken);
authResult.Properties.UpdateTokenValue("expires_at", refreshed.ExpiresAtUtc.ToString("o"));
await context.SignInAsync(authResult.Principal, authResult.Properties);
}
await _next(context);
}
The middleware is registered in Startup.Configure after the standard
UseAuthentication and before UseAuthorization, so it's the very first
thing that runs after the cookie has been deserialised.
If RefreshTokenAsync itself fails (e.g. refresh token expired or revoked),
the middleware lets the request through with the now-stale expires_at; the
downstream [Authorize] attribute then 401s the user and the React shell
redirects to login.
AuthServiceBase — the OAuth client
AuthServiceBase (MediaiBox.Cms.FrontEnd.Auth.OAuth/AuthServiceBase.cs) is
the abstract base for all OAuth grant-type clients in the BFF. It uses a
pooled HttpClient via IHttpClientFactory (named client
"AuthorizationServer") to avoid socket exhaustion under load.
public abstract class AuthServiceBase
{
protected readonly IHttpClientFactory _httpClientFactory;
protected readonly IAuthorizationServerConfig _config;
protected async Task<TokenResponse> ExchangeAsync(IDictionary<string, string> form, CancellationToken ct)
{
var http = _httpClientFactory.CreateClient("AuthorizationServer");
var req = new HttpRequestMessage(HttpMethod.Post, _config.TokenEndpoint)
{
Content = new FormUrlEncodedContent(form)
};
var res = await http.SendAsync(req, ct);
// ... parse, validate, return TokenResponse { AccessToken, RefreshToken, ExpiresAtUtc }
}
public Task<TokenResponse> RefreshTokenAsync(string refreshToken, CancellationToken ct = default)
=> ExchangeAsync(new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = _config.ClientId,
["client_secret"] = _config.ClientSecret,
}, ct);
}
Concrete subclasses (e.g. AuthorizationCodeAuthService) implement the
initial grant. Subclasses inherit RefreshTokenAsync unchanged.
Cookie configuration
In Startup.ConfigureServices:
services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "oauth";
})
.AddCookie(o =>
{
o.Cookie.Name = ".AspNetCore.Cookies";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Lax;
o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
o.ExpireTimeSpan = TimeSpan.FromHours(24);
o.SlidingExpiration = true;
})
.AddOAuth("oauth", o => { /* ... */ });
SlidingExpiration is what keeps the cookie alive across long sessions: every
request inside the cookie's lifetime resets its expiry.
Authorization Server — what else it does
The Authorization Server is more than an OAuth issuer. It hosts:
- OAuth endpoints —
/oauth/authorize,/oauth/token(configurable). - User and group management UI — admins can create users, assign them to groups, and configure permissions per API client.
- Login UI — there is a new React-based login UI at
apps/authand a legacy server-rendered UI; the toggle isUseNewLoginUiinMibAuthorizationServerConfig(defaults tofalsein 6.0, will become the only option in 7.0). - Password recovery flow — email-based reset.
- 2FA / TOTP — see Lock Server and 2FA below.
It owns its own SQL database with AUTH_* tables (AUTH_USERS,
AUTH_GROUPS, AUTH_PASSWORD_HISTORY, API_CLIENTS, etc.). It does not
share state with the BFF — the BFF only sees what the OAuth cookie carries.
Lock Server and 2FA
The Lock Server is a feature of the Authorization Server that adds a hardware-style second factor: paired devices that must approve login attempts. It supports:
- Pair / Unpair — bind a device to a user.
- TOTP enable/disable — RFC 6238 time-based one-time passwords (Google Authenticator style).
- Operations — gated user operations (e.g. password change) that require TOTP re-validation even after the user is already logged in.
See cms/auth_server/LockServer.md and child docs for the full UI and configuration walkthrough. The BFF is not involved in 2FA — by the time a request reaches the BFF, the cookie already represents a fully-authenticated session.
Permission system
Four actors collaborate to answer "can this user do this on this resource":
flowchart TB
User((Operator))
Shell["React shell<br/>(MibFrontEnd)"]
BFF["FrontEnd Server<br/>(MibServer3)"]
DataAPI["Data API (MibApi)"]
AuthSrv["Authorization Server<br/>(MibAuthorization)"]
PermMS["Permission MS"]
User --> Shell
Shell -- "1\. OAuth login →<br/>.AspNetCore.Cookies" --> AuthSrv
Shell -- "2\. Subsequent API calls<br/>with cookie" --> BFF
Shell --> DataAPI
BFF -- "3\. validate token,<br/>fetch UserInformation" --> AuthSrv
AuthSrv -- "4\. who is this user +<br/>what categories of permission<br/>exist for them?" --> PermMS
BFF -- "5\. /configurablepermissions<br/>(metadata only)" --> PermMS
AuthSrv -- "5\. /configurablepermissions" --> BFF
AuthSrv -- "5\. /configurablepermissions" --> DataAPI
style AuthSrv fill:#dde,stroke:#557
style PermMS fill:#fed,stroke:#a72
Who owns what
- MibAuthorization Server — OAuth 2 issuer, user/group CRUD, login UI,
password recovery, 2FA. Holds the
AUTH_*tables. Detail in cms/auth_server/. - Permission Microservice — owns the meaning of permissions (per
user/group, per API client, per resource). Returns a list of
{objectId, objectType, key, canRead/Write/Delete, isInherited, ...}.objectType∈ {1: source, 2: mediatype, 3: boolean}. Full API reference: cms/microservices/permissions.md. - Data API + FrontEnd Server — each expose a
/configurablepermissionsendpoint that returns aGenericPermissioninventory. This is what permission keys CAN be set, separate from what permissions a given user HAS. The Authorization Server's admin UI queries/configurablepermissionsto know what to show when an admin is configuring permissions for a user/group. Implemented in MibServer3 atMediaiBox.Cms.FrontEnd.Server/Controllers/PermissionsController.cs(PermissionsController.GetConfigurablePermissions) and routed atRouteRegister.cs:106(pattern: "configurablepermissions"). - MibServer3 (this BFF) — consumes permissions during render to decide what to show. Does not own permission data.
ConfigurationPermissions vs Permissions
This naming trips people up. There are two related but distinct concepts:
| Concept | Type / API | Where it lives | Purpose |
|---|---|---|---|
/configurablepermissions (the endpoint) |
GenericPermission (categories + items the AuthorizationServer can render to admins) |
PermissionsController.GetConfigurablePermissions in MibServer3, and the equivalent in the Data API |
"What permissions does this service expose for configuration?" — published by each backend, consumed by AuthorizationServer admin UI |
Permission enum |
enum Permission { CanCreate, CanWrite, CanDelete, CanRead, None } at MediaiBox.Cms.FrontEnd.Model/Dao/Permission.cs |
Used inside MibServer3 on MediaTypeInformation.Permissions |
"Given this user, what are they allowed to do on this resource right now?" — computed per render |
So ConfigurationPermissions is schema (what permissions a service
offers); the Permission enum is instance state (what a user has on a
resource).
/configurablepermissions implementation detail
The endpoint is [AllowAnonymous] — the Authorization Server's admin UI can
call it before the admin has a session with the BFF — but it does support
HTTP caching:
// MediaiBox.Cms.FrontEnd.Server/Controllers/PermissionsController.cs
[HttpGet]
[Route("configurablepermissions")]
[AllowAnonymous]
public async Task<IActionResult> GetConfigurablePermissions(CancellationToken ct)
{
var (permissions, etag) = await _service.GetConfigurablePermissionsAsync(ct);
// 304 short-circuit for repeat callers
if (Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch)
&& ifNoneMatch == etag)
{
return StatusCode(304);
}
Response.Headers["ETag"] = etag;
return Ok(permissions);
}
The ETag is a stable hash of the returned GenericPermission object — so the
Authorization Server's admin UI does not refetch on every page navigation.
Permission flow during a render
When the shell requests /api/v2/display/articles_edit/12345:
- AuthN —
MibTokenRefreshMiddlewarevalidates the OAuth cookie against the AuthorizationServer.IUserRepository.GetUser()returnsUserInformationwithMediaTypePermissions,SourcePermissions, token hash, etc. - AuthZ — per mediatype —
MetaRepository.ResponseToMediaTypeInformationNonCachedreads the mediatype'sblock_write,block_delete,block_createcolumns from the Data API'sMETA_<mediaType>response. Each non-blocked action adds the matching enum toMediaTypeInformation.Permissions.block_createis additionally gated oncontent.Container.CanCreatefrom the API response. - AuthZ — per source field —
MetaRepository.GetSource()readsuser.SourcePermissionsand builds a tree-view of allowed sources viaTreeViewGenerator.Generate(sources). The cache key isSOURCETREEINFORMATION_TOKEN_<user.TokenHash>— this is the one legitimately per-user cache in MibServer3. - Component hideability —
ComponentEvaluatorchecksIHideableComponent.ShouldHideComponentfor each component instance.true→ outcome =SkippedHidden, omitted from the response. - Persistence-time guards —
FormComponentCustomBusinessRuleandRelatedListComponentCustomBusinessRule(MediaiBox.Cms.FrontEnd.Model/UI/BusinessRules/Scopes/Components/...) run on save and can reject the operation with a validation notification. These are customisation hooks, not core permissions — each customer can subclass and ship their own business rule.
IHideableComponent worked example
MibComponentWrapper-based React widgets in a customer customisation often
implement IHideableComponent to hide themselves when the user can't write
— that's how custom edit/validation widgets disappear for read-only users.
A typical implementation:
public class MovieValidationComponent
: MibComponentWrapper, IHideableComponent
{
public Task<bool> ShouldHideComponent(ComponentContext ctx, CancellationToken ct)
{
var permissions = ctx.MediaTypeInformation?.Permissions ?? Permission.None;
return Task.FromResult(!permissions.HasFlag(Permission.CanWrite));
}
}
The Permissions flags property is set during render-time per the steps
above — by the time ShouldHideComponent runs, the per-mediatype permission
gates are already resolved.
Microservices, one by one
Five microservices share the CMS bus. Each is a standalone Docker container
with its own MibConfig (via env vars) and is consumed via a NuGet-packaged
client SDK.
Permission MS
Role. The single source of truth for "what is this user/group allowed to do, per API client, per resource." Does not know the meaning of the permissions it stores — it just returns key/value tuples that the consuming service interprets.
Storage. Shares a SQL DB with the Authorization Server (legacy — it used to be a module of MibAuthorization).
Hot path. A PeriodicTimer-backed background service refreshes the
permission metadata cache every MetadataRefreshIntervalSeconds (default
8600 = ~2.4h). On startup, the host waits up to
MetadataInitialFetchTimeoutSeconds (defaults to "wait forever" in
production) for the first fetch to succeed — readiness probes fail until the
cache is populated, so the service never serves "empty permissions."
Key endpoints.
GET /permission/v1/authorization/apiClient/{userId}/{apiClientId}
GET /permission/v1/authorization/oauthClient/{userId}/{oauthClientId}
GET /permission/v1/authorization/{ownerId}/{permissionType}
POST /permission/v1/authorization
Returned items use a fixed shape with objectType ∈ {1: source, 2: mediatype, 3: boolean} and ownerType ∈ {1: user, 2: group}. Full schema:
cms/microservices/permissions.md.
Consumers. MibApi, MibAuthorization, MibServer3 — each via
MibPermissionMicroServiceClientConfig.ServerUrl.
Failure mode. If the Permission MS is unreachable, the consumer's startup
fails closed (no permission data → no requests accepted). Production
MetadataInitialFetchTimeoutSeconds <= 0 makes this explicit. In dev,
setting it to e.g. 10 lets the consumer start with an empty cache and the
background refresh loop will retry.
EditHistory MS
Role. Logs every operator-induced change to MibObject data — adds, updates, deletes, and relationship changes. Read API lets operators see the audit trail of any record.
Storage. MongoDB (DocumentDB-compatible). Uses MibMongoDBConfig. When
running on AWS DocumentDB, additional TLS + client-certificate config is
required — see cms/microservices/edithistory.md.
Write path. The Data API does not call the EditHistory MS directly.
Instead, MibObjectNotificationConfig registers
MediaiBox.Cms.MicroServices.EditHistory.Listener.MibEditHistoryListener as
a listener on the Data API's MibObjects save pipeline. Every Add/Change/Delete
event fires the listener, which posts to:
POST /edithistory/v1/save/{mediatype}/{registerId}
POST /edithistory/v1/save/batch
with the modified fields, added relations, removed relations, and operation type. The listener decouples the Data API from the EditHistory MS — if the listener fails, the Data API save still succeeds (data integrity comes first).
Read path. The BFF (when an operator clicks "show history") calls:
POST /edithistory/v1/read/{mediatype}/{registerId}?page=N&limit=M
with the user's read-permissions whitelist so the MS can filter related-record references the user shouldn't see.
Operation type enum.
enum ChangeOperationType { Added, Changed, Removed, AddedRelation, RemovedRelation }
Toggle. MibCmsFrontEndServerBaseConfig.UseEditHistoryMicroService = true
on the BFF; MibObjectNotificationConfig must be wired on the Data API. If
either is missing, the FE shows no history.
Concurrency MS
Role. Shows operators which other users are currently editing or viewing the same record. Surfaces as the small avatar icon in the corner of the form when another user is in the same page.
Storage. In-memory only. No persistence. The whole point is "right now" — the service has no business with historical data. If the pod restarts, all concurrency state is lost; the BFF tolerates this by showing nothing until users re-poll.
Mechanism. The shell polls
MibCmsFrontEndServerBaseConfig.UserConcurrencyPollingTime (default 10s).
Each poll sends the current user + page + record; the MS records the entry
with SlidingExpirationTime (default 4–5 min), then returns the list of
other users currently on the same record. When the user navigates away, no
explicit deregister — the entry just expires.
Config keys (server-side):
MibConcurrencyConfig_default_SlidingExpirationTime=00:04:00
MibConcurrencyConfig_default_InMemoryExpirationScanFrequency=00:10:00
MibConcurrencyConfig_default_UserIdCacheExpirationTime=00:01:00
No locking. This is a visibility service, not a locking service. Two users can be in the same record and the only thing the MS does is tell each of them that the other is there. Concurrent saves are arbitrated by ETag-based optimistic concurrency in the Data API, not by the Concurrency MS.
Failure mode. If the MS is unreachable, the React shell silently hides the concurrency icon. Operators can still edit; they just don't see who else is editing.
FileManagement MS
Role. Upload / download files (images, PDFs, video assets) with authenticated, signed URLs. Stands in front of a configurable storage backend (S3, Azure Blob, SMB, FTP, SSH, local disk, Dropbox).
Storage backends. Pluggable via MibStorageLib2Config. Common choices:
- AWS S3 —
MibStorageLib2Config_Default_Type=amazon+ access key / secret / bucket. - Azure Blob —
MibStorageLib2Config_Default_Type=azure+ account name / key / bucket. - Disk —
MibStorageLib2Config_Default_Type=disk+ path. In production this requires a reverse-proxy virtual directory so the CMS can serve image previews. - FTP / SMB / SSH / Dropbox — all supported, all configured similarly.
Auth. Every endpoint requires Authorization: Bearer <token> issued by
MibAuthorization. The token is validated against the AuthorizationServer URL
in MibFileManagementMicroServiceServerConfig.MibAuthUrl.
Simplified routes.
POST /api/v1/upload/content/{filename}
GET /api/v1/download/file/{fileName}
These use MibStorageLib2Config_Default_Type to pick the storage backend.
For more advanced flows (multipart upload, MD5 validation, presigned URL
generation), see the Swagger docs at <service>/help.
Client SDK. MediaiBox.Cms.MicroServices.FileManagement.Client is a NuGet
package consumed by MibServerApi, MibServer3, and the TsvExporter agent. It
handles retry/back-off via MaxRetries + RetryInterval.
Imagery in the CMS. When the FE renders an <img> for a media reference,
the URL is built by combining MibMediaConfig_StorageArea<N>_Default (the
public-facing base URL) with the file's path. Disk-backed storage needs
the reverse-proxy mapping mentioned above; cloud-backed storage uses the
provider's CDN/signed URL.
Image-vs-file classification. ImageFileExtensions in the server config
declares which file extensions to treat as images for preview purposes; this
is purely cosmetic and does not affect storage.
ContentCriteria MS
Role. Evaluates content selections — the filter expressions composed by operators in the ContentCriteria Component. For example, "all movies with genre = drama AND release-year >= 2020."
Position. Sits behind MibServerApi (the Data API), not behind the BFF. The BFF never calls it directly. The Data API calls it when a query that uses content-criteria needs to be expanded into a concrete list of record IDs.
Storage. Uses the same SQL content database as MibServerApi
(MibDatabaseConfig).
Config (consumer side, on MibServerApi).
MIBCONTENTCRITERIAMICROSERVICECLIENTCONFIG_DEFAULT_URLMICROSERVICE=mibcontentcriteriaservice
MIBCONTENTCRITERIAMICROSERVICECLIENTCONFIG_DEFAULT_TIMEOUT=100 # seconds
MIBCONTENTCRITERIAMICROSERVICECLIENTCONFIG_DEFAULT_RETRYINTERVAL=5 # seconds
MIBCONTENTCRITERIAMICROSERVICECLIENTCONFIG_DEFAULT_MAXRETRIES=5
Port. 8637 by default inside the container.
Failure mode. If the MS is unreachable, content-criteria-backed queries fail; static (non-criteria) queries are unaffected. Bound by the consumer's retry policy.
Inter-service topology
A request to the BFF that produces a full page render fans out to up to five backends in parallel + N more in series. Realistic case for an edit page:
BFF -> Authorization MS (1 call, token check)
BFF -> Permission MS (1 call, cached per-user)
BFF -> Data API (N calls, batched: meta + item + related)
Data API -> ContentCriteria MS (1 call if list uses criteria)
Data API -> EditHistory MS (async listener, fire-and-forget)
BFF -> EditHistory MS (1 call if "show history" tab is open)
BFF -> Concurrency MS (1 call, polled every 10s — separate request)
BFF -> FileManagement MS (1 call per uploaded asset reference, lazy)
Most production-customer pages are rendered in 50–200 ms despite the fan-out
because Task.WhenAll parallelises the BFF's outbound calls. The slowest
single backend usually dominates the total.
Page templates, components, and customisation
This section describes how a page is defined, not how it's rendered (the render path is above).
Page templates in the FrontEnd DB
Each page in the CMS is a row in PAGE joined to N rows in PAGE_COMPONENT.
| Table | Columns of interest |
|---|---|
PAGE |
PAGE_KEY (e.g. articles_edit), MEDIA_TYPE_NAME, TITLE, BREADCRUMB_PARENT_PAGE_KEY, LAYOUT_TYPE |
PAGE_COMPONENT |
TEMPLATE_COMPONENT_KEY (unique within page), COMPONENT_TYPE_KEY (e.g. form, list, or a customer-defined string like my_custom_widget), ORDER, CONFIGURATION_JSON |
The ORDER column drives SortComponents. CONFIGURATION_JSON is the
serialised IComponentConfiguration (per-instance config — column choice,
default values, page size, etc.).
A new page goes through the Mib3 Development Tool (cms/frontend_server/local-development.md), which writes these rows and reloads the FE in dev. In production the rows are migrated through migration scripts. See cms/frontend_server/pages.md for the operator-facing flow.
Component types
COMPONENT_TYPE_KEY is a string that maps to an IComponent implementation
in C#. The registry is populated at startup by scanning every loaded
assembly's [ComponentType] attributes, which is why customer customisations
"just appear" once their DLLs are dropped into /app/.
| Category | Examples |
|---|---|
| Core types | list, form, relatedList, orderedRelatedList, relatedForm, relatedAutoComplete, contentcriteria, dmm, message, iframe |
| Stock React widgets | permissionList, asyncRelatedList, epg |
| Customer customisation | Customer-chosen strings — anything the customer registers via [ComponentType("...")] and exposes from their federated remote. |
Each customer's *.csproj declares its components via [ComponentType]
attributes; the matching React widget is exported from the federated
remote.
End-to-end: creating a custom component
A typical "new widget" change touches three repos:
1. C# side — register the component. In your customisation csproj, add a class:
[ComponentType("my_custom_review_component")]
public class MyCustomReviewComponent
: MibComponentWrapper<MyCustomReviewConfig, MyCustomReviewData, MyCustomReviewSchema>,
IHideableComponent
{
public override Task<MyCustomReviewSchema> MapSchema(object viewData, CancellationToken ct)
{
// shape the schema for the React side
}
public override Task<MyCustomReviewData> MapData(object viewData, CancellationToken ct)
{
// optional — return null and fetch from React if async
}
public Task<bool> ShouldHideComponent(ComponentContext ctx, CancellationToken ct)
{
return Task.FromResult(!ctx.MediaTypeInformation.Permissions.HasFlag(Permission.CanWrite));
}
}
MibComponentWrapper is the base class supplied by MibServer3 — it implements
the boilerplate of IRenderableComponentV2 and gives you typed Schema/Data
without forcing you to wire the reflection-based dispatch yourself.
2. React side — register the widget. In your federated remote, add a
component file and re-export it from src/main.tsx:
// src/components/MyCustomReview/index.tsx
export const MyCustomReview = (props: MibComponentProps<Schema, Data>) => {
// ...
};
// src/main.tsx
import { MyCustomReview } from './components/MyCustomReview';
export default {
components: {
// ... existing components
'my_custom_review_component': MyCustomReview,
},
};
The string 'my_custom_review_component' must match the C#
[ComponentType("my_custom_review_component")]. If they don't, the BFF
emits the component in schema.components but the React shell can't dispatch
it and console-warns.
3. Page template — add the component to a page. Either through the Mib3
Development Tool UI (recommended in dev) or via a migration script
(production). The Mib3 Development Tool generates the right PAGE_COMPONENT
row including a JSON config.
4. Deploy.
- The C# change ships as part of the customer's customisation DLLs via the
next
deploy_mib_frontend_<env>.shrun. - The React change ships as a new image of the federated remote; nginx
serves the new
remoteEntry.jswithno-cacheheaders, so the next shell load picks it up. - The page template change ships via DB migration.
5. Storybook for dev. Each component should ship with a *.stories.tsx
file so it can be developed in isolation against mock schema/data, decoupled
from a running BFF. The mock-server package provides MSW handlers that mimic
the BFF's response shape.
Caching strategy
Caching is critical to RenderV2 hitting its sub-100ms latency budget on
warmed-up pods. Everything in the BFF goes through MibCache.AutoCacheAsync,
which wraps IMibCache.GetObjectAsync / AddAsync on the underlying backend
(in-memory, Memcached, or a Couchbase-compatible cluster — see
framework/cache/couchbasecache.md).
Cache keys, by domain
| Key pattern | Stored object | Scope | Invalidation |
|---|---|---|---|
META_<mediaType> |
MediaTypeInformation (fields, edit flags, related links, enum bundle reference) |
Pod-shared (was per-user; PR fixed) | TTL + manual flush on metadata edit |
RELATED_FIELD_INFO_<sourceMt>_<relatedMt>_<fieldName> |
Field-projection metadata for a related-mediatype list view | Pod-shared | TTL |
ENUM_METADATA_BUNDLE |
EnumMetadataBundle { FieldIdToEnumTypeId, EnumTypeValues } |
Pod-shared | TTL; rebuilt lazily on cache miss |
SOURCETREEINFORMATION_TOKEN_<user.TokenHash> |
TreeViewSource[] — the user's allowed-source tree |
Per-user (intentionally) | TTL; new key on token refresh |
RENDER_PAGE_<pageKey> |
RenderPageInformation (the template) |
Pod-shared | TTL + flush on page edit |
CONFIGURABLE_PERMISSIONS |
GenericPermission for /configurablepermissions |
Pod-shared | TTL; ETag is hash of value |
The pivot from "per-user META_<mt> keyed by userId+token" to "pod-shared
META_<mt>" was a major refactor — the data is intrinsically user-independent
because permission filtering happens after the metadata is loaded, on the
MediaTypeInformation.Permissions field. Removing the user scoping divided
the cache fill rate by hundreds across warm pods. See the change set under
"Meta optimisation" in the MibServer3 history.
The Enum bundle
Every mediatype has fields, and many fields are enum-typed. To avoid a
per-mediatype enum-table fetch, the entire enum dictionary for the
deployment is fetched once into ENUM_METADATA_BUNDLE:
public sealed class EnumMetadataBundle
{
// ADM_FIELDS.id → ADM_ENUM_TYPES.id
public IReadOnlyDictionary<int, int> FieldIdToEnumTypeId { get; }
// ADM_ENUM_TYPES.id → ordered list of (value, label, position)
public IReadOnlyDictionary<int, IReadOnlyList<EnumValue>> EnumTypeValues { get; }
}
MetaRepository.ResponseToMediaTypeInformationNonCached references this
bundle by ID, so adding 100 enum-typed mediatypes adds 100 dictionary lookups
rather than 100 round-trips.
When caches get user-scoped (and when they shouldn't)
The SOURCETREEINFORMATION_TOKEN_<TokenHash> key is the only per-user cache
that legitimately exists. Source permissions are inherently per-user — a
user's allowed-source tree depends on which sources they (and their groups)
have been granted read/write/delete on. The cache key uses TokenHash (a
SHA-1 of the access token) rather than UserId directly so that a token
revocation invalidates the cache even if the user later re-logs-in: the new
token's hash differs.
Everything else must not be user-scoped. Meta is the same for every user; permission filtering of meta happens at usage time, not at cache time.
Refresh strategy
There's no general "subscribe to changes" mechanism. Cache entries have TTLs ranging from minutes (Permission MS metadata) to hours (page templates), plus manual flushes triggered by the Mib3 Development Tool when an admin edits a page or mediatype.
In dev, you can force a cache flush by restarting the BFF pod
(kubectl delete pod -l app=mib-frontend). Production uses an admin endpoint
or pod restart depending on the customer.
Database conventions
Three SQL databases plus one MongoDB:
| Database | Owner | Table prefix | Examples |
|---|---|---|---|
| Auth DB | Authorization Server + Permission MS (shared, legacy) | AUTH_*, API_CLIENTS, OPERATIONS, PERMISSION_* |
AUTH_USERS, AUTH_GROUPS, API_CLIENTS, OPERATIONS, PERMISSION_USER_OBJECT |
| Content DB | Data API + ContentCriteria MS (shared) | ADM_* (metadata), META_<mediaType> (per mediatype, custom-named) |
ADM_FIELDS, ADM_RELATEDS, ADM_MEDIATYPES, ADM_ENUM_VALUES, plus customer-defined META_<MediaType> tables (e.g. META_ARTICLES) |
| FrontEnd DB | MibServer3 | MIB_*, PAGE_*, MENU_* |
PAGE, PAGE_COMPONENT, MIB_USER_PREFERENCES, MENU |
| EditHistory DB | EditHistory MS | (Mongo collections) | edit_history, indexed by mediaType + registerId + timestamp |
The customer-specific META_<mediaType> tables are dynamically created by
the Data API based on the operator's mediatype-modelling actions. Field
metadata for them lives in ADM_FIELDS (with MEDIATYPE_NAME as the join
column). See cms/datamodel/MetaModel.md for the
data model in detail.
Migrations
All four DBs are bootstrapped and evolved through migrations.
MibClient2.MibDatabaseMigrations.dll ships the foundational tables; each
service then ships its own migration assembly:
MibAuthorizationServer.MibDatabaseMigrations.dllMediaiBox.Cms.Api.Database.Migrations.BasicObjects.dllMediaiBox.Cms.FrontEnd.Database.Migrations.dll
Run via the MibMigrator CLI (see
framework/tools/migrator.md). For high-volume
batch migrations (e.g. backfilling a new column on every row), use
MibBulkMigrator instead.
Environments and deploys
Environment shape (generic, per customer)
Every customer typically runs a small set of environments — names vary by customer, but the shape is consistent:
| Role | Typical pattern | Engineer overlays? | Notes |
|---|---|---|---|
| Engineering dev | One or more dev-* environments on a shared cluster | Yes — floating :mib-frontend-latest tag re-pulled per overlay deploy |
Active iteration; engineers push DLL overlays here for testing. |
| Customer QA / pre-prod | Pinned-version environment(s) on the same or a parallel cluster | No — only released images via CI | Customer-side validation. Reproducible builds, no engineer side-loading. |
| Production | Customer-owned cluster (cloud or on-prem) | No — releases-only | Released images deployed via the customer's standard release process. |
Cluster type is also customer-dependent — EKS, AKS, GKE, OpenShift, on-prem Kubernetes, and even bare-metal Docker Compose deployments are all in use. The MIB framework is cluster-agnostic; the only constraint is that the BFF container can reach the Data API, the microservices, and the customer federated remote(s) over HTTP(S).
Deploy mechanics (generic shape)
The shape of a per-customer deploy script is the same across customers, only the customisation solution name and image tags differ:
dotnet build MibServer3.sln -c Release— produces MibServer3 own DLLs + transitive NuGet dependencies.dotnet build <Customer>.Mib3.sln -c Release— produces every<Customer>.Mib3.*.dllfor that customer.- Stage overlay. Glob
MediaiBox.*.dll + BeIT.*.dll + <Customer>.Mib3.*.dll, filtered against the base image's/app/*.dll. docker buildx build --platform linux/amd64 -t <repo>:mib-frontend-latest .docker push <repo>:mib-frontend-latest- Restart the running pod (cluster API call or
kubectl rollout restart);imagePullPolicy: Alwaysre-pulls the floating tag.
A common refinement is to auto-derive the DLL set from
git diff origin/<release-branch>..HEAD so that a small change overlays a
small number of DLLs — fast cycle times for iterative debugging.
Cluster API access
Pod management on locked-down clusters is typically via a cluster-management
API (Rancher, Argo CD, or vendor-specific dashboard) rather than direct
kubectl access. The deploy script encapsulates the auth + endpoint details
so an engineer never needs to know the exact shape:
# Conceptual — exact endpoint depends on cluster management tool
GET <cluster-api>/namespaces/<ns>/pods/<pod>/log?tailLines=200 # tail
DELETE <cluster-api>/namespaces/<ns>/pods/<pod> # restart
MibAuthorization and MibApi image publishing
Two additional notes apply equally to the Authorization Server and Data API deploy pipelines:
- MibAuthorization images require a
Bootstrap.configand a customisation overlay or the pod crashes on/app/configsearch. - MibApi images require the same
Bootstrap.config+ customisation overlay convention.
The overlay pattern from Backend overlay system applies identically — only the base image and assembly set change.
Observability
Four structured log lines instrument the BFF. All are [prefix] {json}
emitted as a single line each so Kibana / Logstash can decode the message
field without grok-parsing:
| Prefix | One per | Where |
|---|---|---|
[RenderV2-Stats] |
Page render | DisplayWorkflow.LogRenderV2Stats |
[Render-ComponentFailure] |
Failed component | ComponentEvaluator.LogComponentFailure |
[OnSave-Stats] |
Form / RelatedList save | OnSaveStatsLogger.EmitFormOnSave / EmitRelatedListOnSave |
[OnDelete-Stats] |
Form delete | OnSaveStatsLogger.EmitFormOnDelete |
Each one's fields and example queries are documented in the
RenderV2 telemetry reference
inside the MibServer3 repo at docs/renderv2-stats.md.
[RenderV2-Stats] example
{
"evt": "RenderV2-Stats",
"pageKey": "articles_edit",
"totalMs": 142,
"componentCount": 11,
"components": [
{ "key": "articles_template_001", "type": "form", "outcome": "Success", "schemaMs": 12, "dataMs": 38 },
{ "key": "articles_template_002", "type": "list", "outcome": "Success", "schemaMs": 4, "dataMs": 21 },
{ "key": "articles_template_003", "type": "my_custom_widget", "outcome": "SkippedHidden", "schemaMs": 1, "dataMs": 0 },
{ "key": "articles_template_004", "type": "my_custom_widget", "outcome": "SuccessDataNull", "schemaMs": 8, "dataMs": 0 }
]
}
totalMs is wall-clock for the whole GetRenderV2 (minus middleware);
per-component schemaMs + dataMs is per-component time spent inside the
evaluator. Because evaluation is parallel, sum(schemaMs+dataMs) is
typically larger than totalMs — the largest single component bounds
totalMs, not the sum.
[Render-ComponentFailure] example
{
"evt": "Render-ComponentFailure",
"pageKey": "articles_edit",
"componentKey": "articles_template_007",
"componentType": "my_custom_widget",
"phase": "MapSchema",
"exception": "System.NullReferenceException",
"message": "Object reference not set to an instance of an object.",
"stack": "..."
}
One line per failed component, paired with the parent [RenderV2-Stats] by
correlation context (pageKey + scope-bound requestId).
[OnSave-Stats] example
{
"evt": "OnSave-Stats",
"pageKey": "articles_edit",
"mediaType": "ARTICLES",
"registerId": 12345,
"operation": "Form",
"totalMs": 318,
"businessRulesMs": 41,
"persistenceMs": 274,
"modifiedFieldCount": 6,
"addedRelationCount": 1,
"removedRelationCount": 0
}
Replaces a 14-line plain-text emission that was previously the only signal. Shipped as part of PR #1875.
Roadmap: metrics, not just logs
This is "logs only" today. The roadmap target is OpenTelemetry (Activity +
ActivitySource for traces, System.Diagnostics.Metrics histograms / counters
scraped by Prometheus). The structured-log format above is already correct
shape for the metric variant — adding metrics means also emitting, not
changing.
Kibana / log-aggregation queries
The structured shape lets you query without parsing:
# All slow renders in last hour
evt: "RenderV2-Stats" AND totalMs: >500
# All failed components in last 24h, grouped by componentType
evt: "Render-ComponentFailure" | stats count by componentType
# All saves by a specific user
evt: "OnSave-Stats" AND userId: 12345
The exact query syntax depends on the customer's log stack (Kibana, Datadog, or Splunk).
Glossary
| Term | Meaning |
|---|---|
| BFF | Backend-for-Frontend. The MibServer3 .NET service. Aggregates downstream APIs and emits the page's {schema, data} for the shell. |
| Shell | The React host app at apps/cms in MibFrontEnd. |
| Remote | A federated React app whose remoteEntry.js the shell loads at runtime. Each customer publishes one or more. |
| Overlay | A docker image built FROM a customer base image with extra DLLs copied into /app/. Used to deploy local MibServer3 / customer-customisation changes without rebuilding the entire customer image. |
| MediaType | A data type in the Data Model (a customer-defined entity such as ARTICLES, MOVIES, CHANNELS, etc.). |
| TemplateComponent | One slot on a page template. Identified by TemplateComponentKey. |
| PageKey | The page identifier in the URL (customer-defined string, e.g. articles_edit, articles_list, …). |
| ADM_FIELDS / adm_enumvalues / etc | Data API metadata tables. The schema cascade in MetaRepository walks these to build MediaTypeInformation. |
/configurablepermissions |
The endpoint each backend exposes to advertise which permissions are configurable on it. Schema, not instance. |
Permission enum |
{CanCreate, CanWrite, CanDelete, CanRead, None} — applied per mediatype to a user. Instance state. |
block_write / block_delete / block_create |
Columns on the mediatype metadata row that suppress the matching Permission enum value if true. |
SourcePermissions |
User-scoped per-source tree of (sourceId, Write, Delete) → SourcePermissionModelType. Drives Source-typed field visibility. |
IHideableComponent |
Optional interface on a C# component class that lets it hide itself at render time based on the request context. |
MibComponentWrapper |
Base class supplied by MibServer3 that implements IRenderableComponentV2 so customisation classes only need to override MapSchema and MapData. |
| Authorization Code grant | OAuth grant type used by MibServer3. OAUTH_CLIENT_TYPE = 1 in API_CLIENTS. The most secure grant — user credentials never reach the BFF. |
| Resource Owner Password grant | OAuth grant type used by MibServerApi, FileManagement MS, etc. OAUTH_CLIENT_TYPE = 3. Used for service-to-service auth. |
| TokenHash | SHA-1 of the OAuth access token. Used as a cache key in the BFF so token revocation invalidates the cache. |
.AspNetCore.Cookies |
The encrypted authentication cookie written by HttpContext.SignInAsync after OAuth exchange. Carries access + refresh tokens + expires_at. |
| MibConfig | The MIB framework's hierarchical configuration system. Reads from XML files at <app>/config/*.mibconfig or env vars matching MIB<CONFIG>_<SECTION>_<KEY>. |
| MibCache | The framework's caching abstraction. AutoCacheAsync is the standard "get-or-fetch" wrapper. Backends: in-memory, Memcached, Couchbase-compatible. |
| MibObjects | The data-access library underlying the Data API. Maps MediaType + Id + Field queries to SQL. |
| MibMigrator | The CLI tool for running SQL migrations. Assemblies in NuGet MediaiBox.Cms.Api.Database.Migrations.BasicObjects etc. |
| DocFX | The static-site generator producing docs.agilesvcs.com/mediaibox/. Markdown + Mermaid + PlantUML. |
See also
Inside MibDocs
- About — high-level CMS architecture
- Mib v6.0 — What's New
- Mib v6.0 — React Components (per-type schema/data contracts)
- Mib v6.0 — Async Changes
- FrontEnd Server: Page Configuration
- FrontEnd Server: Component Creation
- FrontEnd Server: Customization (CSS/colours/logo)
- FrontEnd Server: Rendering JavaScript
- FrontEnd Server: Javascript Bindings
- FrontEnd Server: Localization
- FrontEnd Server: Mib3 Development Tool
- FrontEnd Server: Auditing
- FrontEnd Server: Concurrency Viewer
- FrontEnd Server: Plugin — Custom Validation
- FrontEnd Server: Plugin — Custom Business Rule
- FrontEnd Server: Plugin — Image URL Resolver
- FrontEnd Server: Plugin — Page Persistence
- Microservices — Permissions
- Microservices — Concurrency
- Microservices — Edit History
- Microservices — File Management
- Microservices — Content Criteria
- Authorization Server — Installation
- Authorization Server — Configuration
- Authorization Server — Lock Server
- Authorization Server — TOTP / 2FA Flows
- Server API — Introduction
- Server API — Security
- Server API — Media Descriptor
- Server API — Auditing
- Data Model — Meta data model
- Data Model — User data model
- Data Model — Content Sources
- Data Model — Permissions
- Data Model — Page Creation
- Framework — MibCore configuration
- Framework — MibCache configuration
- Framework — MibLog configuration
- Framework — Couchbase cache backend
- Framework — Migrations (Get Started)
- Framework — MibMigrator tool
- Framework — MibBulkMigrator tool
- Framework — MibCodeGenerator tool
- CMS HealthChecks
- TSV Exporter
- Docker — Get Started with Mib v5.0
- Podman — Get Started with Mib v5.0
External / outside this repo
- Published doc portal: http://docs.agilesvcs.com/mediaibox/ (same DocFX site, rendered from this repo)
- MibServer3 repo: https://github.com/agilecontent/MibServer3
- MibFrontEnd repo: https://github.com/agilecontent/MibFrontEnd
- NuGet feed: https://nuget.agilesvcs.com
Customer-specific repos (customisation csprojs and federated React remotes) live under https://github.com/agilecontent/ alongside the framework repos — their names are customer-specific and intentionally not listed here, since the framework is agnostic to which customisation is overlaid on top.
Treat the local ~/Documents/agilecontent/MibDocs tree as the canonical
source. The portal is downstream of this repo.