Table of Contents

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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.
  3. 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:

  1. Checks IHideableComponent.ShouldHideComponent (if implemented). True → outcome = SkippedHidden, component is dropped from the response.
  2. Calls MapSchema(viewData) via MibReflection.GetMethodResultAsync. The reflection step is what lets old-style components and new-style IRenderableComponentV2<TConfig, TData, TSchema> share one dispatch path.
  3. If schema came back non-null, calls MapData(viewData) similarly.
  4. Catches any thrown exception, logs [Render-ComponentFailure] {json}, marks outcome = Errored.
  5. Returns a ComponentEvaluation struct.

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/MapData calls fan out via Task.WhenAll(componentEvalTasks). The result array preserves submission order, which is viewData.Components order, which is template-walk order.
  • Resilience. A single broken widget cannot take down the page. Errored components 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. SortComponents is stable OrderBy(Order) + ThenBy(TemplateComponentKey, StringComparer.Ordinal). The primary key is the Order field on DisplayComponentViewData, which is overridden by DisplayComponentPreferencesWorkflow when the user has stored tab preferences. Ties resolve alphabetically by TemplateComponentKey.
  • Per-render telemetry. A single [RenderV2-Stats] JSON line is emitted per render with per-component schemaMs/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 the MediaTypeInformation (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/canDelete derived from the user's permissions), related-mediatype hints, validation rules.
  • MapData(viewData) fetches the actual item via IItemRepository.GetItem(mediaType, id) and projects field values into a { fieldName: value } dictionary. For Form, it also calls IRelatedRepository.GetDirectRelateds to 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 as assets/remoteEntry.js.

  • Shape of the entry: an object the host merges into its component registry. Each entry maps a type string (matching the BFF's schema[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:

  1. customComponents — the customer's core widgets.
  2. customComponents2 — a subsidiary brand's widgets, or a feature-flag bundle that ships ahead of the core.
  3. 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:

  1. dotnet build MibServer3.sln -c Release — produces MibServer3 own DLLs and copies every transitive NuGet dependency into the bin folder.
  2. dotnet build <Customer>.Mib3.sln -c Release — produces every <Customer>.Mib3.*.dll for that customer (often 100+ DLLs).
  3. Stage overlay. Glob MediaiBox.*.dll + BeIT.*.dll + <Customer>.Mib3.*.dll from the bin folders; filter against the base image's /app/*.dll so separate-pod DLLs (Agent.EditHistoryTsvExporter, TsvExporter, Tests) don't sneak in.
  4. docker buildx build --platform linux/amd64 + docker push.
  5. Delete the running pod via the cluster API (Rancher in many setups); imagePullPolicy: Always re-pulls the floating :mib-frontend-latest tag.

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-beta4never 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.

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:

  1. OAuth endpoints/oauth/authorize, /oauth/token (configurable).
  2. User and group management UI — admins can create users, assign them to groups, and configure permissions per API client.
  3. Login UI — there is a new React-based login UI at apps/auth and a legacy server-rendered UI; the toggle is UseNewLoginUi in MibAuthorizationServerConfig (defaults to false in 6.0, will become the only option in 7.0).
  4. Password recovery flow — email-based reset.
  5. 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 /configurablepermissions endpoint that returns a GenericPermission inventory. This is what permission keys CAN be set, separate from what permissions a given user HAS. The Authorization Server's admin UI queries /configurablepermissions to know what to show when an admin is configuring permissions for a user/group. Implemented in MibServer3 at MediaiBox.Cms.FrontEnd.Server/Controllers/PermissionsController.cs (PermissionsController.GetConfigurablePermissions) and routed at RouteRegister.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:

  1. AuthNMibTokenRefreshMiddleware validates the OAuth cookie against the AuthorizationServer. IUserRepository.GetUser() returns UserInformation with MediaTypePermissions, SourcePermissions, token hash, etc.
  2. AuthZ — per mediatypeMetaRepository.ResponseToMediaTypeInformationNonCached reads the mediatype's block_write, block_delete, block_create columns from the Data API's META_<mediaType> response. Each non-blocked action adds the matching enum to MediaTypeInformation.Permissions. block_create is additionally gated on content.Container.CanCreate from the API response.
  3. AuthZ — per source fieldMetaRepository.GetSource() reads user.SourcePermissions and builds a tree-view of allowed sources via TreeViewGenerator.Generate(sources). The cache key is SOURCETREEINFORMATION_TOKEN_<user.TokenHash> — this is the one legitimately per-user cache in MibServer3.
  4. Component hideabilityComponentEvaluator checks IHideableComponent.ShouldHideComponent for each component instance. true → outcome = SkippedHidden, omitted from the response.
  5. Persistence-time guardsFormComponentCustomBusinessRule and RelatedListComponentCustomBusinessRule (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 S3MibStorageLib2Config_Default_Type=amazon + access key / secret / bucket.
  • Azure BlobMibStorageLib2Config_Default_Type=azure + account name / key / bucket.
  • DiskMibStorageLib2Config_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>.sh run.
  • The React change ships as a new image of the federated remote; nginx serves the new remoteEntry.js with no-cache headers, 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.dll
  • MediaiBox.Cms.Api.Database.Migrations.BasicObjects.dll
  • MediaiBox.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:

  1. dotnet build MibServer3.sln -c Release — produces MibServer3 own DLLs + transitive NuGet dependencies.
  2. dotnet build <Customer>.Mib3.sln -c Release — produces every <Customer>.Mib3.*.dll for that customer.
  3. Stage overlay. Glob MediaiBox.*.dll + BeIT.*.dll + <Customer>.Mib3.*.dll, filtered against the base image's /app/*.dll.
  4. docker buildx build --platform linux/amd64 -t <repo>:mib-frontend-latest .
  5. docker push <repo>:mib-frontend-latest
  6. Restart the running pod (cluster API call or kubectl rollout restart); imagePullPolicy: Always re-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.config and a customisation overlay or the pod crashes on /app/config search.
  • 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

External / outside this repo

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.