Skip to content

Module Suite Runtime

Module Suite Runtime (MSR) is the runtime a developer uses in Module Suite 4.1.0 (Polaris) for pages, components, and remote forms. MSR runs outside OpenText Content Server.

MSR supersedes Script Console

Script Console is discontinued. Starting with Module Suite 4.1.0, MSR supersedes it. Move Script Console workloads to MSR, and use MSR for new projects. The Script Console pages remain for existing installations only.

On MSR you build a portal from Shell Pages and Shell Components, mount AI forms as remote forms, and call Content Server as the signed-in user. This page describes what runs on MSR, how pages and components are stored, how access and authentication work, and how MSR is deployed.

Architecture

The browser talks to the MSR portal. MSR renders the page, decides which components the user may see, and hands the browser the code and configuration it needs. Content Server data is requested by the browser directly, with the user's delegated token.

flowchart LR
    Browser["Browser"]
    subgraph MSRBox["Module Suite Runtime (MSR)"]
        Portal["Portal: Shell Pages"]
        Components["Shell Components"]
        Forms["Remote forms (AI View)"]
        Builder["Component AI Builder"]
        Storage["Versions: database and bucket"]
    end
    CS["OpenText Content Server"]
    OTDS["OTDS"]
    Aether["Aether"]

    Browser -->|"page request"| Portal
    Portal --> Components
    Portal --> Forms
    Portal --> Storage
    Builder --> Storage
    Builder -->|"generation"| Aether
    Browser -->|"REST with bearer token"| CS
    Browser -->|"Smart View widgets"| CS
    MSRBox -->|"OAuth 2.0 sign-on"| OTDS

The portal serves Shell Pages and mounts Shell Components and AI Views in their regions. Calls to Content Server and Smart View widgets go from the browser to Content Server with a bearer token issued through OTDS; the Component AI Builder uses Aether for generation.

What runs on MSR and what runs on Content Server

Concern On MSR On Content Server
Page rendering MSR renders every portal page on the server, with the session of the signed-in user Supplies data only
Page controller Runs as a Content Script on MSR Not involved
Component storage Versions are stored on MSR, addressed by a Component Key A component on Content Server is a node
Access control Access Lists on pages and components Content Server permissions on its own data
Data Access Lists decide where components are mounted, not who may read data Remains the authority for its data, read and written through REST as the portal user
Smart View widgets Not shipped with MSR Loaded from the Content Server the page names
Identity Session and delegated connections OTDS issues the tokens

Controller code is not portable

A page controller follows the same contract on both hosts, but its code is not portable. A Content Server controller uses docman; on MSR there are no nodes, so a controller written for one host must be adapted for the other.

The portal

The portal hosts Shell Pages and Shell Components. A Shell Page is the page the portal serves. A Shell Component is a part mounted on that page. System components can be edited in the Component AI Builder.

Every portal URL is a server-rendered Shell Page: /portal/{pageKey}, and /portal/ for the home page. Rendering depends on who is signed in, so portal pages use the session of the signed-in user.

Shell Pages and the page controller

A Shell Page version on MSR has two parts and its page settings:

Part Content
template.vm The Velocity view that places the regions
controller.cs The page controller. It is required

The controller runs as a Content Script with the trigger PORTAL_PAGE. Delegation is allowed. The script is bound to model (with data and raw), params, request, response, user, page, and log.

The controller must set model.data.shellDefinition. The definition has regions, a registry, and optionally sharedState and initialState. Registry entries name Component Keys, because MSR has no node IDs. Each registry entry takes one of these forms:

{
  "registry": {
    "my.component": {},
    "my.pinned": { "version": 3 },
    "my.alias": { "component": "my.component" }
  }
}

An empty entry serves the active version of the component. An entry with version pins one version. An entry with component mounts the component stored under another key. For the region definition and the rest of the shell contract, see Shell pages and Shell components.

Follow these rules when you author a page:

  • A page key is one path segment that matches ^[a-z0-9][a-z0-9-]{0,62}$.
  • Everything the template inserts is HTML-escaped, except $raw.* and the host references.
  • Saving a version writes the controller. If the controller cannot be written, the save is rolled back.

The portal layout and navigation

One active portal layout wraps every Shell Page. The layout is layout.vm with a declarative layout.json. layout.vm must contain three references:

Reference Content
$amShellHead Runtime and engine styles, branding custom properties, and the import map
$pageBody The rendered page template
$amShellScripts The resolved definition, the host configuration, and the host script

layout.json adds layout regions and registry entries. They are merged with the regions and entries of the page.

Region collisions

A region defined by both the layout and the page is a render error. A save warns about a collision in the templates when it can detect it, but the render is the final check.

The layout has no controller, so it cannot change per request. A page that needs different chrome uses regions of its own.

Navigation is the system component portal.nav. It reads GET /api/portal/shell/navigation, which returns the pages the caller may open that have showInNav set, in navOrder, and the signed-in user. One page carries isHome.

System components

The portal views are system components: portal.nav, portal.home, portal.forms, portal.submissions, portal.documents, portal.connections, and portal.login. The home page is itself a Shell Page.

Each system component is a single ShellView.vue that imports only vue and @amshell/runtime, together with component.json, locales.json, and source.css. Because the files are self-contained, you can open, regenerate, edit, and reset a system component in the Component AI Builder. The builder edits source.css and previews the compiled styles.css.

The runtime gives components these generic features:

Area Features
REST rest.json (throws RestError on a non-2xx response), rest.upload with progress, blob responses, rest.url
Navigation navigation.pageUrl, navigation.toPage, navigation.currentPageKey
Localization t(key, params), has, formatDate, formatNumber
Theme useTheme, useBreakpoint, useBranding
Kit FilePreview, UploadQueue

Portal API calls use the api: URL scheme, which the MSR host resolves like the cs: prefix. The host never turns a 401 into a redirect, because public pages call APIs that answer 401 to anonymous visitors. Only rest.json reports a 401 to the host, and the host redirects to the sign-in page when the page was rendered for a signed-in user.

Colours, radii, and the font reach components as host theme tokens, and the logo and colours you need in script come from useBranding(). Components that share state use Shared State keys: declare each key with an initial value in the page controller and list it in the component.json of every component that uses it.

Style with the portal tokens

A component in a page region always renders its own frame, because page CSS cannot reach into a shadow root. Use the portal token classes for colour and radius so the component follows the portal appearance and dark mode. The build warns about hard-coded colours and literal image URLs.

Storage and versions

Shell Components, Shell Pages, and the portal layout are stored as immutable versions on MSR. Metadata is kept in the MSR database and the packages in the portal-shell bucket.

stateDiagram-v2
    [*] --> Inactive: deploy or save creates a version
    Inactive --> Active: activate or restore
    Active --> Inactive: another version is activated
    Inactive --> Deleted: delete version
    Deleted --> [*]

Every deploy or save creates a new version, and exactly one version is active. Activate and restore only move the pointer to the version the portal serves, so a rollback is atomic and a version you have not activated works as a draft.

Version rules you can rely on:

  • Version numbers are never reused. Deleting a component or page removes every version and file, and re-creating the key continues numbering after the last number used.
  • You can delete a version that is neither active nor the product default.
  • A page controller pinned to a deleted component version fails to render.
  • Concurrent deploys are safe across nodes.
  • Component keys that start with portal. are reserved for product components.

The component package format is the same on MSR and on Content Server, so a component exported from either host imports into the other. Its component.json must be authorized.

Product components and reset

Product components, the default pages, and the default layout are installed as ordinary versions when MSR starts. When an upgrade changes a product item, the new item becomes a new version. It is activated only while the previous product version is still the active one, so an upgrade never replaces your customization. Page updates keep the current page settings.

Reset to product default activates the product default version. Product items cannot be deleted. Their other versions can, except the product default version that Reset returns to.

Component bundles

On deploy, the bundle.js and styles.css of a component are published as public static assets under /static/amshell/components/{key}/{hash}/. The hash is computed from the authorized component.json, bundle.js, and styles.css. Identical content reuses the same folder, and different content never overwrites a published path, even after a delete and re-create. Browsers and caches can keep a bundle indefinitely.

The public path can be changed with portal-shell.bundle-path. If your deployment does not use the MSR proxy, route /static/amshell/ to the bucket yourself.

Bundles are public

Anyone who knows or guesses the URL can read a component bundle. Never compile secrets into a bundle. Access Lists decide where a component is mounted, not who can read its code; the backends remain the authority for data.

Public assets

The files in the asset store, which you manage in the Asset Manager of the management console, are served anonymously at /static/<path>. Every asset is public: anyone who knows its URL can read it, but the store cannot be listed. Use the asset store for the logos, style sheets, fonts, and images that pages without a signed-in user need:

  • public forms and public portal pages
  • the login and PIN pages
  • emails
  • Smart UI pages hosted on Content Server

Keep confidential files in the resource store, not in the asset store.

Every asset is publicly readable

Every file in the asset store can be read by anyone who knows or guesses its /static/ URL, without signing in. Never upload confidential documents, personal data, or secrets as assets.

Folder headers

An administrator can set HTTP response headers on an asset folder, for example Cache-Control, X-Frame-Options, or Access-Control-Allow-Origin. The headers are added to every /static/ response for the files in that folder.

  • A folder inherits the headers of its ancestors, and its own headers override inherited headers of the same name.
  • A sub-folder without headers of its own gets the headers of its nearest ancestor that has them. This also applies to folders that appear in the store through an upload, a move, or a copy.
  • A folder header replaces a header of the same name sent by the storage or by MSR, including the default Content Security Policy. Folder headers can add or replace a header; they cannot remove one.
  • The proxy keeps the headers of each folder for 60 seconds, so a header change reaches every proxy within a minute.
  • Access-Control-Allow-Origin: * is fine for public files; fonts loaded from another origin need it. Never combine it with Access-Control-Allow-Credentials.

Folder headers apply only to /static/ responses. A download from the Asset Manager does not carry them.

Headers a client receives

Every customer asset response from /static/ carries these headers:

Header Value
Content-Security-Policy sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; font-src 'self', unless a folder header replaces it
X-Content-Type-Options nosniff

Browsers apply a Content Security Policy only to a file that is opened as a page or loaded as a worker. Images, style sheets, and fonts used by other pages are not affected. An uploaded HTML or SVG file that a user opens directly runs in a sandboxed origin without script, so it cannot read MSR cookies or call MSR APIs. A folder that really hosts pages or worker scripts can set its own Content-Security-Policy folder header, which brings that risk back for the files in that folder.

sequenceDiagram
    participant Browser
    participant Proxy as MSR proxy and cache
    participant MSR as MSR folder headers lookup
    participant Storage as Asset store
    Browser->>Proxy: GET /static/{folder}/{file}
    Proxy->>Proxy: Check the folder path
    alt Folder headers not kept by the proxy
        Proxy->>MSR: Look up the folder headers
        MSR-->>Proxy: Headers of the folder or of its nearest ancestor
    end
    alt File not in the proxy cache
        Proxy->>Storage: Fetch the file
        Storage-->>Proxy: File
    end
    Proxy->>Proxy: Set the sandbox policy, then apply the folder headers
    Proxy-->>Browser: File with its headers

The headers are applied to every response, including files served from the proxy cache. A path with a . or .. segment, a backslash, or a control character is rejected with 400 and never leaves /static/.

Folder and file names

Folder names with spaces and non-ASCII letters are accepted, both in the Asset Manager and in /static/ URLs. Percent-encode each path segment in a URL.

The proxy routes these top-level paths to product files, never to customer assets: /static/anscontentscript/, /static/ansbwebform/, /static/anssmartui/, /static/aifb/, and /static/amshell/. The Asset Manager does not create a top-level folder with one of these names.

Asset Manager

UI label What it does
Public URL Shown in the details of a file: the full /static/ URL of the file
Copy public URL Copies the public URL of the file to the clipboard. The confirmation is Public URL copied to clipboard; on failure, Could not copy the public URL
This top-level name is reserved for MSR product files Shown when you create a top-level folder with a reserved name

The Asset Manager page states: "Every asset is public: anyone who knows its /static/ URL can read it, so keep confidential files in Resources."

Without the MSR proxy

/static/ is served by the MSR proxy. An installation that does not use the proxy has no /static/ route.

Access lists

MSR has no nodes, so pages and components carry an Access List instead of Content Server permissions. An Access List contains principals:

Principal Matches
anonymous Any visitor
authenticated Any signed-in user
role:X Users with the authority ROLE_X
perm:X Users with the permission authority X
user:name One user

MSR has no groups.

  • When a page's Access List does not admit the visitor, an anonymous visitor is redirected to /portal/login?returnTo=… and a signed-in user receives 403.
  • When a component's Access List does not admit the user, the component is dropped from the registry. A region whose initial component was dropped starts empty, so one page can mix public and restricted regions.
  • requiredPermissions in component.json is only a user-experience gate.
  • Portal administration requires the PORTAL_MANAGE permission. ADMIN and DEVELOPER hold it.

Forms

A form on MSR does not require the OpenText script-value library used on Content Server.

List endpoints return stable page JSON. Classic form templates can use $forms.

An AI form can run on MSR as a remote form. The AI Form Builder produces the form, and an AI View can be mounted on a Shell Page. A default remote-form action refreshes the form: the submitted values are validated and stored, and the form is shown again.

To mount an AI View, add a registry entry with aiView under any key that does not start with portal.. version is optional:

{
  "registry": {
    "intake-form": { "aiView": "{formId}:{viewId}", "version": 2 }
  }
}

When the region loads, MSR calls POST /forms/{formId}/aifb/runtime-state. The call runs the OnLoad script of the view, stores the form state on the server, and returns a token for that state. The OnLoad result is kept after an action roundtrip.

sequenceDiagram
    participant User
    participant Browser
    participant MSR as MSR
    User->>Browser: Open Shell Page
    Browser->>MSR: POST /forms/{formId}/aifb/runtime-state
    MSR->>MSR: Run OnLoad and store form state
    MSR-->>Browser: State token
    User->>Browser: Submit
    Browser->>MSR: Default remote-form action
    MSR->>MSR: Validate submitted values
    MSR->>MSR: Store values
    MSR-->>Browser: Refreshed form
    Browser-->>User: Form shown again

The region submits with the default action. MSR validates and stores the values and the user stays on the same form, which is shown again with the stored values.

A mounted AI View follows the remote-form rules of that form:

  • A PIN-protected form is left off the page until the PIN has been verified in the session.
  • A form that is expired, inactive, or not visible to the user is left off the page.

PIN-protected forms

A region does not show a PIN prompt. Publish a PIN-protected form as a standalone form; it can appear in a region only after the PIN has been verified in the session.

The same AI View can be a standalone page and a region on a Shell Page.

Exporting a form

msr.exportForm(id) exports a form, with its classic views and its AI views, from Module Suite Runtime as an .amf form package. Use msr.importForm() to import that package into another Module Suite Runtime instance. Since 4.1.0.

Syncing submissions to Content Server

A public form often runs on MSR while the process behind it runs on Content Server. A scheduled Content Server script reads the submissions through the msr scripting service, stores the files users attached, and submits a record of a Content Server form for each submission. The members added in 4.1.0 are additive: existing calls keep their signatures.

flowchart LR
    PublicUser["Public user"]
    RemoteForm["MSR remote form (AI View with a Droparea)"]
    MsrStorage["MSR storage: submissions and attached files"]
    SyncScript["Content Server sync script (msr service)"]
    CsFolder["Content Server folder for each submission"]
    CsRecord["Content Server form record"]
    MsrDelete["Delete on MSR: submission and its files"]

    PublicUser -->|"submits values and files"| RemoteForm
    RemoteForm -->|"stores"| MsrStorage
    SyncScript -->|"msr.listSubmissions, oldest first"| MsrStorage
    SyncScript -->|"msr.downloadSubmissionAttachment"| MsrStorage
    SyncScript -->|"stores the files"| CsFolder
    SyncScript -->|"forms.mapToForm, then forms.submitForm"| CsRecord
    SyncScript -->|"msr.deleteSubmission after the submit"| MsrDelete
    MsrDelete -->|"removes"| MsrStorage

The script deletes a submission on MSR only after the Content Server record was submitted, so a failed run leaves the submission on MSR for the next run.

The msr service on Content Server

Method Description Since
msr.listSubmissions(formId, page, size, status) Lists the submissions of a form with pagination. status is DRAFT or SUBMITTED 4.0.0
msr.listSubmissions(formId, page, size, status, from, to, sort) Lists the submissions of a form with a submission date range and a sort order. size is at most 200. from and to are Date values and both are inclusive. sort is desc (newest first, the default) or asc (oldest first); ties are ordered by ID 4.1.0
msr.getSubmission(formId, subId) Retrieves one submission 4.0.0
msr.listSubmissionAttachments(formId, subId) Lists the files attached to a submission, as CSMsrAttachment objects 4.1.0
msr.downloadSubmissionAttachment(formId, subId, uploadId) Downloads an attached file as a CSResource. The resource name is the original file name; the content was uploaded by the form user and is not trusted 4.1.0
msr.deleteSubmissionAttachment(formId, subId, uploadId) Deletes an attached file. Its value in the submission data becomes an empty string; set rows are kept. Fails when the submission was changed at the same time 4.1.0
msr.deleteSubmission(formId, subId) Deletes a submission 4.0.0
msr.deleteSubmissions(formId, submissionIds) Deletes several submissions in one batch and returns the result as a Map 4.0.0
msr.downloadDocument(formId, docId) Downloads a document of the form package, attached to the form when it was deployed. For files uploaded by form users, use downloadSubmissionAttachment 4.0.0
CSMsrSubmission.attachments The files attached to the submission. Empty when the MSR user is not a forms manager 4.1.0

A CSMsrAttachment (since 4.1.0) has these properties:

Property Content
uploadId The attachment identifier (a UUID), used to download or delete the file
name The original file name, sanitized by MSR
size The size in bytes
contentType The content type the browser declared. It is not verified
field The top-level field of the submission data that holds the file
member The set member that holds the file, or null outside a set
row The set row index, starting at 0, or null outside a set

sub.data maps each field name to a list of values, and each set to a list of rows. forms.mapToForm(Map, Form, boolean exactNames) with exactNames set to true copies it into a Content Server form and keeps the MSR field names as they are; see API additions in Module Suite 4.1.0.

The same operations are available over HTTP on MSR:

Route Purpose
GET /api/forms/{id}/submissions Lists submissions. Query parameters page, size, status, and, since 4.1.0, from and to (ISO-8601 instants, inclusive) and sort (desc or asc, ordered by submittedAt, then ID). Each submission includes its attachments
GET /api/forms/{id}/submissions/{subId}/attachments Lists the attached files of a submission. 404 when the form has no such submission
GET /api/forms/{id}/submissions/{subId}/attachments/{uploadId} Downloads an attached file, always as application/octet-stream and as an attachment
DELETE /api/forms/{id}/submissions/{subId}/attachments/{uploadId} Deletes an attached file. 204 when deleted, 404 when there is no such file, 409 when the submission was changed at the same time

Choosing a sync strategy

MSR keeps no marker of which submissions were imported, so the script decides how it tracks progress.

Strategy How it works Pick it when
Import, then delete (recommended) Read the submitted records oldest first, import each one, then call msr.deleteSubmission. A submission still on MSR is not imported yet Content Server is the system of record. Deleting the submission also deletes its files on MSR
Cursor on submittedAt Do not delete. Store the submittedAt of the last imported submission and the IDs imported with that same date. Read the next page with from set to that date and sort set to asc, and skip the IDs already imported, because from includes its own date. Do not move the cursor past a submission that failed The submissions must stay on MSR

Run only one sync at a time: two runs at the same time can import a submission twice.

Access to attached files

  • Only users with ADMIN or FORM_MANAGE can read or delete the attached files of a submission through /api/forms. FORM_SUBMIT does not give access.
  • Use an msr profile on Content Server whose MSR user has FORM_MANAGE. Without it, sub.attachments is empty and downloads are refused. This user can also delete forms, so keep its credentials safe.
  • Downloads are always application/octet-stream. The content type the browser declared is metadata only.
  • Anyone can upload files to a public form, and MSR checks only the content type the browser declares. Scan the files with an antivirus, or check their extension, before users open them. Content Server sets the MIME type of a document from its file extension.
  • Two files can have the same name. Prefix the document name with the upload ID, which also makes a retry safe.

Example: Import MSR submissions into a Content Server form

This scheduled Content Server script imports the submitted records of a remote form with the import-then-delete strategy. The Content Server form needs a submission mechanism that creates a new record for each submitForm, a text field msrSubmissionId, and a multi-value item reference field for the documents. The folder name marks progress: <id>.pending holds the files, and it becomes <id> after the Content Server submit.

def MSR_FORM_ID = "b6c1..."                                    // id of the remote form on MSR
def CS_FORM_ID  = 2844025L                                     // dataid of the Content Server form
def ROOT_FOLDER = docman.getNodeByPath("Intake:Attachments")   // path from the Enterprise Workspace
def DOCS_FIELD  = "attachedDocuments"                          // CS item reference field (multi-value)
def MAX_PER_RUN = 200

int done = 0
int imported = 0
while (done < MAX_PER_RUN) {
    def page = msr.listSubmissions(MSR_FORM_ID, 0, 50, "SUBMITTED", null, null, "asc")
    if (page.content.isEmpty()) {
        break
    }
    int deletedThisPage = 0
    page.content.each { sub ->
        try {
            // 1. Already submitted on CS in an earlier run? Then only delete it on MSR.
            if (docman.getNodeByName(ROOT_FOLDER, sub.id)) {
                msr.deleteSubmission(MSR_FORM_ID, sub.id)
                deletedThisPage++
                return
            }

            // 2. Store the files. The upload id prefix makes names unique and makes a retry safe.
            def pendingName = sub.id + ".pending"
            def folder = docman.getNodeByName(ROOT_FOLDER, pendingName) ?: docman.createFolder(ROOT_FOLDER, pendingName)
            def nodeIds = sub.attachments.collect { att ->
                def docName = att.uploadId.take(8) + "_" + att.name
                def existing = docman.getNodeByName(folder, docName)
                if (existing) {
                    return existing.ID
                }
                def res = msr.downloadSubmissionAttachment(MSR_FORM_ID, sub.id, att.uploadId)
                return docman.createDocument(folder, docName, res.content).ID
            }

            // 3. Fill a new CS record. The upload sets are removed: the CS form stores node IDs.
            def form = forms.getFormInfo(CS_FORM_ID)
            def data = new HashMap(sub.data)
            sub.attachments*.field.unique().each { data.remove(it) }
            forms.mapToForm(data, form, true)
            form.msrSubmissionId.value = sub.id
            form[DOCS_FIELD].setValueList(nodeIds)

            // 4. Validate on CS. Skip this submission if CS refuses the values.
            form.validate()
            if (!form.getValidationStatus().isEmpty()) {
                log.error("MSR submission {} not valid on CS: {}", sub.id, form.getValidationStatus())
                return
            }

            // 5. Submit on CS, mark the folder as done, then delete on MSR (this also deletes the MSR files).
            forms.submitForm(form)
            docman.renameNode(folder, sub.id)
            msr.deleteSubmission(MSR_FORM_ID, sub.id)
            deletedThisPage++
            imported++
            log.info("Imported MSR submission {}", sub.id)
        } catch (Exception e) {
            log.error("MSR submission {} not imported; the next run tries again", sub.id, e)
        }
    }
    done += page.content.size()
    if (deletedThisPage == 0) {
        break                                                  // only failures left: stop, do not loop
    }
}
return [imported: imported]

A failure before forms.submitForm leaves the submission on MSR and the <id>.pending folder with the files already copied, and the next run reuses them. A failure after the submit and before msr.deleteSubmission leaves the <id> folder, so the next run only deletes the submission on MSR. A submission that Content Server refuses stays on MSR and is logged at every run until the values or the form are corrected. The loop stops when a page has only failures.

Prepare the two forms

Publish the MSR form from Content Server with forms.createExPackage and msr.importForm, so the field names are the same on both forms. Give the upload set on MSR a name that the Content Server form does not use: the script removes that set before it fills the Content Server record.

Maintaining submissions on MSR

A scheduled script on MSR decides how long saved drafts and submitted records stay. It uses the csforms scripting service.

Method Description Since
csforms.getSubmissions(formId) Retrieves all submissions of a form. Each submission includes attachments 4.0.0
csforms.getSubmissions(formId, status, updatedBefore) Retrieves the submissions with status DRAFT or SUBMITTED that were last changed before updatedBefore, oldest first, expired drafts included. At most 1000 for each call 4.1.0
csforms.getSubmission(submissionId) Retrieves one submission, with attachments, or null 4.0.0
csforms.listSubmissionAttachments(submissionId) Lists the attached files of a submission as maps with uploadId, name, size, contentType (declared by the browser), field, member, and row. Empty when the submission does not exist 4.1.0
csforms.getSubmissionAttachment(submissionId, uploadId) Retrieves an attached file as a CSResource with its original name. Fails when the submission has no such file 4.1.0
csforms.deleteSubmissionAttachment(submissionId, uploadId) Deletes an attached file and returns true, or false when it is not found. Fails when the submission was changed at the same time 4.1.0
csforms.deleteSubmission(submissionId) Deletes a submission together with its stored form state and its attached files 4.0.0

Attachment rules

  • Users attach files with a Droparea in an AI form. Content Server has no Droparea upload handler for AI forms, so collecting attachments applies to AI forms that run on MSR.
  • By default, a file can be up to 10 MB, and a page up to 20 files and 50 MB; an administrator can change these limits. MSR refuses files whose declared type is HTML, XHTML, SVG, or JavaScript.
  • Deleting a submission always deletes its pending uploads and its stored files. The delete is written to the audit log.
  • Deleting one attachment removes the file and its upload record and sets its value in the submission data to "". The set row is kept, so the other rows keep their index. The stored form state is not changed.
  • There is no move method. To move a file elsewhere, copy it, then delete it.
  • Without a script, MSR already deletes uploads that were never submitted after 24 hours, files that the user removed from a draft before saving it again, and uploads claimed by a submit that failed validation.

Do not archive public files in the MSR resource store

Every MSR user with DOCUMENT_READ can browse and download the MSR resource store (csdocman), and the PORTAL role has DOCUMENT_READ. Do not copy files from a public form there unless you control which users have this permission.

Example: Clean up submissions on MSR

Schedule this script on MSR, for example every night in Management > Scheduler. A scheduled MSR script has no log binding, so it writes to out.

def FORM_ID = "b6c1..."
def now = new Date()

// 1. Drafts that nobody changed for 30 days.
csforms.getSubmissions(FORM_ID, "DRAFT", now - 30).each { sub ->
    try {
        csforms.deleteSubmission(sub.id)                      // also deletes the files
        out.println("Deleted draft ${sub.id}")
    } catch (Exception e) {
        out.println("Draft ${sub.id} not deleted: ${e.message}")
    }
}

// 2. Submitted records older than 180 days.
csforms.getSubmissions(FORM_ID, "SUBMITTED", now - 180).each { sub ->
    try {
        // Optional copy. Use a location that only trusted users can read,
        // and use att.uploadId in the target name: two files can have the same name.
        // csforms.listSubmissionAttachments(sub.id).each { att ->
        //     def res = csforms.getSubmissionAttachment(sub.id, att.uploadId)
        //     // send res to your archive here
        // }
        csforms.deleteSubmission(sub.id)
        out.println("Deleted submission ${sub.id}")
    } catch (Exception e) {
        out.println("Submission ${sub.id} not deleted: ${e.message}")
    }
}

Delegated authentication

Delegated OpenText authentication uses OTDS OAuth 2.0.

  • A bearer token is accepted only from a trusted issuer and a trusted client.
  • After sign-on, the browser returns to the requested page.
sequenceDiagram
    participant Browser
    participant MSR as MSR portal
    participant OTDS
    Browser->>MSR: GET /portal/{pageKey}
    MSR-->>Browser: Redirect to /portal/login?returnTo=...
    Browser->>OTDS: Sign on with OAuth 2.0
    OTDS-->>MSR: Bearer token
    MSR->>MSR: Check trusted issuer and trusted client
    alt Issuer and client trusted
        MSR-->>Browser: Redirect to requested page
    else Not trusted
        MSR-->>Browser: Token rejected
    end

An anonymous visitor to a restricted page is sent to sign-on with the requested page in returnTo. MSR accepts the bearer token only when both its issuer and its client are trusted, and then returns the browser to the page it asked for.

Calling Content Server from a page

A Shell Page declares one delegated connection. The connection must be an enabled OpenText connection that allows a browser token, and it can name a support path.

When a signed-in user renders the page, MSR puts a connection ticket into the host configuration. The ticket is valid for 8 hours. The host exchanges it at GET /api/delegated-connections/{id}/browser-token?ticket=…, which returns accessToken, expiresAt, baseUrl, and supportPath. The response is not cached. A token is issued only when the ticket is valid, names this user and this connection, and the page still declares that connection. Every issuance is audited, without the token.

Address Content Server with the cs: URL prefix. Each host resolves the prefix, so one bundle runs on Content Server and on MSR:

rest.get('cs:/api/v2/nodes/2000')

The MSR host sends Content Server requests with Authorization: Bearer. It refreshes the token 60 seconds before it expires and retries once on 401. A 409 opens a Connect prompt that returns to the page.

Portal pages send a Content Security Policy with a per-response nonce. Connections, scripts, styles, images, and fonts are admitted only from the portal itself and the Content Server origin of the page. Pages with a connection also allow 'unsafe-eval' for Smart View templates.

Components can reach the user's token

Components run in the JavaScript realm of the page. A malicious component on a page with a connection can obtain that user's token. Deploy only components you trust: only PORTAL_MANAGE holders deploy components, and every deploy is audited with the user and the content hash.

Host widgets from Content Server

Smart View widgets are not shipped with MSR. The MSR host loads Smart View from the Content Server of the page, so a page always uses the Smart View version that server runs. The bearer header is updated when the token rotates. There is no perspective, so every widget mount gets its own page context, focused on the node its options name.

Widget Required option
nodestable containerId
documentoverview nodeId
metadata nodeId
relatedworkspaces workspaceNodeId
workspace busObjectType, busObjectId, extSystemId
workspaceheader workspaceNodeId

Pages with widgets allow 'unsafe-eval' in their Content Security Policy.

Component AI Builder

The Component AI Builder generates a Shell Component, previews it, and deploys it. A deploy from the Component AI Builder stores the new component as a new version. One version is active. Activating or restoring a version selects the version the portal serves.

The builder deploys components. After a deploy, it can propose the Shell Page, the region, and the initial properties for the component. An administrator applies that assignment on the page. The Shell Page stays authored separately from the component.

On MSR the builder is served at /modules/portal/componentbuilder/ and requires PORTAL_MANAGE. It is the same builder as on Content Server and uses the same helper protocol, with the Component Key as the component ID. Generation access uses the csai.generative.* settings. Page templates and controllers stay hand-authored; the builder does not generate them.

flowchart TD
    Generate["Generate in the Component AI Builder"] --> Preview["Preview"]
    Preview --> Deploy["Deploy"]
    Deploy --> NewVersion["New component version"]
    NewVersion --> Proposal["Proposal: page, region, initial properties"]
    Proposal --> Apply["Administrator applies it to the page controller"]
    Apply --> Served["Portal serves the component in the region"]

A deploy always creates a new version, and the builder only proposes where the component goes. You stay in control of the page, because an administrator applies the proposal to the page controller.

A datasource test in the builder resolves cs: against a connection you choose, with an audited preview token. The builder can also read the runtime source and the MSR REST catalog.

Deployment

This section is for developers and for the administrators who deploy MSR for them. It describes what an MSR deployment consists of, how to configure it, and how the three supported deployment options differ.

Choosing a deployment option

Option Pick it when
Docker Compose You evaluate MSR or run it on one host. Everything, including the database, Redis, and object storage, runs on that host in single-node mode
AWS ECS You run one MSR instance on AWS without Kubernetes. One task runs MSR with its own Redis; the database and buckets are managed AWS services. A deploy causes a short outage
Kubernetes You need several replicas, autoscaling, and rolling updates. MSR can be installed with the Module Suite Runtime Helm chart; the database, Redis, and object storage are external services

The mode is set by one variable, CSR_MODE: Docker Compose uses single-node, AWS ECS and Kubernetes use multi-node.

Common components and requirements

Every option runs the same two images and needs the same backing services.

Component Role
Proxy An OpenResty reverse proxy, the only entry point. It listens on port 80, forwards application traffic to the app, and serves /static/ from the static-assets bucket
App The MSR application. It serves /csconsole on port 8080; in multi-node mode the health and probe endpoints are on the management port 9190
Migration step The app image started with the migration profile. It applies the database migrations and exits before the app starts. In multi-node mode the app itself never migrates the database
PostgreSQL Relational data, including the metadata of Shell Pages and components
Redis Sessions, caches, and the messages that invalidate cached scripts on every node. MSR treats Redis as a hard dependency
Object storage S3 or an S3-compatible store for scripts, static assets, resources, remote-form data, portal-shell packages, MCP resources, and DocuSign webhook data
OpenText Content Server and OTDS External systems. MSR needs outbound HTTPS access to them
flowchart LR
    Users["Browsers and MCP clients"]
    TLS["TLS termination: load balancer, Ingress, or proxy"]
    subgraph MSRDeploy["MSR deployment"]
        Proxy["Proxy (port 80)"]
        App["App (port 8080)"]
        Migrate["Migration step (multi-node)"]
    end
    Redis["Redis"]
    DB["PostgreSQL"]
    S3["Object storage (S3-compatible)"]
    CS["OpenText Content Server"]
    OTDS["OTDS"]

    Users -->|"HTTPS"| TLS
    TLS -->|"HTTP"| Proxy
    Proxy -->|"/csconsole"| App
    Proxy -->|"/static/"| S3
    Migrate -->|"schema migrations"| DB
    App --> DB
    App --> Redis
    App --> S3
    App -->|"outbound HTTPS"| CS
    App -->|"outbound HTTPS"| OTDS

The migration step finishes before the app starts. The app reaches Content Server and OTDS over outbound connections, so those systems see the outbound address of the network MSR runs in; allowlist that address if they restrict callers.

TLS. On AWS ECS, TLS ends at the load balancer. In Kubernetes, it ends at the Ingress, with a certificate from cert-manager or from the load balancer. The Docker Compose proxy also publishes port 443 and keeps certificates in the certs volume.

Content Server libraries. Extensions that integrate with Content Server need the OpenText SDK JARs in /app/lib/ext in the app container. Without them, the Content Server services (docman, users, search, workflow, and others) and the extension services that depend on them, such as sql, carl, and rend, are disabled at startup. llm, openai, zip, html, odata, cache, and csws still run, and their Content-Server-only features fail with a message that says so.

Plugins. Plugins and internal extensions are part of the app image. To add one, build or re-tag the image and roll it out; do not copy JARs into a running container.

Configuration common to every option

The app and the migration step read the same environment variables.

Runtime and database

Variable Description Required
CSR_MODE single-node or multi-node Yes
SERVER_PORT HTTP port of the app. The deployments use 8080 No
CONTEXT_PATH Context path of the app. The deployments use /csconsole No
SPRING_PROFILES_ACTIVE multi-node for the app and multi-node,migration for the migration step Multi-node
DB_URL JDBC URL of the PostgreSQL database, for example jdbc:postgresql://<host>:5432/<database> Yes
DB_USERNAME Database user Yes
DB_PASSWORD Database password. Store it as a secret Yes
REDIS_URL Redis URL, for example redis://<host>:6379, or rediss:// for Redis with TLS Yes, unless Sentinel is used
REDIS_SENTINEL_MASTER, REDIS_SENTINEL_NODES Redis Sentinel master name and comma-separated nodes. Sentinel wins when both Sentinel and REDIS_URL are set No
ENCRYPTION_KEY Base64-encoded 32-byte key that encrypts stored OAuth client secrets and delegated connection tokens. Delegated connections are disabled without it Yes, for delegated connections
ADMIN_INITIAL_PASSWORD Replaces the seeded admin password on the first migration; ignored once the password has been changed No
JAVA_OPTS JVM options, for example -XX:MaxRAMPercentage=75 No
LOG_FORMAT json for structured logs No
CACHE_ETAG_TTL Script ETag cache lifetime in seconds; the upper bound on staleness if a cache invalidation is missed No

Keep the encryption key

Back up ENCRYPTION_KEY outside the deployment. A database restored from a backup needs the same key. With a different key, MSR cannot read the stored secrets and users must reconnect their delegated connections.

Public URL and identity

Variable Description Required
OAUTH2_ISSUER_URL The public MSR URL including /csconsole, for example https://msr.example.com/csconsole. OAuth clients check tokens against this issuer Yes
DELEGATION_PUBLIC_BASE_URL Browser-facing MSR URL including /csconsole, used in the connect links sent to MCP clients. Defaults to the issuer URL No

The deployment sources define no variables for OTDS or Content Server. Delegated connections to them are configured in MSR, as described in Delegated authentication.

Storage and buckets

Variable Description Required
SCRIPT_STORE s3: scripts are stored in object storage Yes
S3_ENDPOINT Endpoint of an S3-compatible store. Leave it unset for native AWS S3 No
S3_REGION Storage region Yes
S3_ACCESS_KEY, S3_SECRET_KEY Static credentials. When unset, MSR uses the default AWS credentials chain, such as an ECS task role or a Kubernetes service account role No
S3_BUCKET Physical name of the scripts bucket Yes
S3_PREFIX Key prefix for scripts in that bucket, for example scripts/ No
ASSETS_S3_BUCKET Static-assets bucket No
RESOURCE_S3_BUCKET Resource-store bucket No
FORM_REMOTE_S3_BUCKET Remote-form bucket No
PORTAL_SHELL_S3_BUCKET Portal-shell bucket for Shell Page and component packages No
MCP_S3_BUCKET MCP resources bucket No
DOCUSIGN_WEBHOOK_BUCKET DocuSign webhook bucket No
S3_BUCKET_PREFIX Prefix added to the bucket names that scripts pass to csadmin No

A bucket variable that is not set falls back to the application default. The logical bucket names are script-console, static-assets, resource-store, form-remote, formbuilder, portal-shell, mcp-resources, and docusign.

S3 bucket names are global, so an installation can use physical names such as <prefix>-docusign while scripts keep using logical names:

  • Buckets bound in configuration get their physical names from the bucket variables above.
  • A bucket name a script passes to csadmin gets S3_BUCKET_PREFIX. listBuckets shows only prefixed buckets, under their logical names.
  • The aws extension works on your own buckets and is not prefixed.

Module flags

Variable Description Required
FORM_REMOTE_ENABLED Turns on remote forms No
PORTAL_ENABLED Turns on the portal No
MCP_ENABLED Turns on the MCP server No
DOCUSIGN_ENABLED Turns on DocuSign No

Set modules in the environment

The module settings changed in the UI are kept on the container's disk, which is replaced when the container restarts. Where a deployment sets the module flags, change them there; a module deactivated in the UI is active again after a restart.

Only trusted script authors

Scripts run in the application process and can read its environment and credentials, including ENCRYPTION_KEY, DB_PASSWORD, and the cloud role of the container. Only trusted administrators of the instance should author scripts.

Docker Compose

The Docker Compose setup runs MSR and every backing service on one host, in single-node mode.

Service Image Role Ports and volumes
openresty ${DOCKER_IMAGE}:proxy Proxy, the entry point Publishes 7070:80 and 443:443; volumes openresty-cache, certs
app ${DOCKER_IMAGE}:app MSR application Port 8080 on the internal network only; volumes app-data (/data), certs, and ./lib/ext mounted read-only at /app/lib/ext for the OpenText SDK JARs
postgres pgvector/pgvector PostgreSQL with the pgvector extension, which hybrid Knowledge Corpus retrieval requires Publishes 5432; volume postgres-data
redis redis Redis Publishes 6379; volume redis-data
rustfs rustfs/rustfs S3-compatible object storage Publishes 9000; volume rustfs-data
rustfs-init amazon/aws-cli Creates the buckets, then exits. The app waits for it None
rustfs-subscribe amazon/aws-cli Subscribes the script-console bucket to the app webhook /csconsole/api/webhook/s3, so a changed script is reloaded None

DOCKER_IMAGE sets the image name for the app and proxy images; it defaults to default/script-console. Services start in order: the app waits until PostgreSQL and Redis are healthy and the buckets exist, and the proxy waits until the app is healthy.

docker compose build
docker compose up -d

Open the management console at http://localhost:7070/csconsole/management/. Stop the stack with docker compose down.

Replace the example credentials

The Compose file sets example database and storage credentials in plain text and publishes the PostgreSQL, Redis, and storage ports on the host. Replace the credentials and remove the published ports before you use the stack beyond evaluation.

Single-node mode is not for a load balancer

In single-node mode the proxy expects the services named app and rustfs, and its HTTP-to-HTTPS redirect can loop behind a load balancer that terminates TLS. Use AWS ECS or Kubernetes when MSR runs behind a load balancer.

AI Gateway with Docker Compose

The optional AI Gateway is an MCP backend. It starts only with the ai-gateway profile, together with its own PostgreSQL and a bootstrap job:

AM_OBOT_ENABLED=true docker compose --profile ai-gateway up -d
Variable Description Required
AM_OBOT_ENABLED true turns on the AI Gateway integration in the app Yes, for the AI Gateway
AI_GATEWAY_IMAGE AI Gateway image No
PUBLIC_BASE_URL Public browser URL of MSR, without /csconsole. The AI Gateway is served under /ai-gateway Yes, outside localhost
OAUTH2_ISSUER_URL Must be the public URL plus /csconsole; the AI Gateway trusts tokens from this issuer Yes, outside localhost
OBOT_BOOTSTRAP_TOKEN One-time token for the initial AI Gateway setup Yes
AM_OBOT_INTERNAL_SHARED_SECRET Shared secret that signs calls between the AI Gateway and MSR Yes

The AI Gateway publishes a debug port 1234 and is also reachable through the proxy at /ai-gateway/. Its service mounts the host Docker socket.

AWS ECS

On AWS ECS, MSR runs as one task with four containers.

Container Image Role
redis redis (Alpine) Cache and pub/sub only, with a memory cap and no persistence
migrate App image Applies the database migrations and sets the first admin password, then exits. Not essential
app App image MSR application on port 8080. Starts after migrate succeeds and redis is healthy
proxy Proxy image Port 80, registered with the load balancer. Starts after app is healthy
flowchart LR
    LB["HTTPS load balancer with certificate"]
    subgraph Task["ECS task"]
        RedisC["redis"]
        MigrateC["migrate"]
        AppC["app"]
        ProxyC["proxy"]
    end
    RDS["RDS PostgreSQL"]
    Buckets["S3 buckets"]
    Secrets["Secrets Manager"]

    RedisC -->|"healthy"| MigrateC
    MigrateC -->|"exits successfully"| AppC
    AppC -->|"healthy"| ProxyC
    LB -->|"HTTP port 80"| ProxyC
    MigrateC --> RDS
    AppC --> RDS
    AppC --> Buckets
    ProxyC -->|"/static/"| Buckets
    Secrets -.->|"DB_PASSWORD, ENCRYPTION_KEY"| AppC

The containers start in a fixed order, so the app never runs against an unmigrated database and the proxy never forwards to an app that is not ready.

Task environment. migrate and app share one environment: CSR_MODE=multi-node, the database, REDIS_URL=redis://localhost:6379, SCRIPT_STORE=s3, S3_REGION, S3_BUCKET_PREFIX, every bucket variable, the four module flags, OAUTH2_ISSUER_URL and DELEGATION_PUBLIC_BASE_URL set to the public URL with /csconsole, AM_OBOT_ENABLED=false, CACHE_ETAG_TTL, and LOG_FORMAT=json. There is no S3_ENDPOINT and there are no S3 keys: the app uses native S3 with the task role. The proxy gets CSR_MODE, APP_BACKEND_HOST=localhost:8080, S3_BACKEND_HOST (the virtual-hosted name of the static-assets bucket), S3_BACKEND_PROTO=https, an empty S3_ASSETS_PATH_PREFIX, and SECURITY_CONTACT, which publishes /.well-known/security.txt when set.

Database. An RDS PostgreSQL instance on port 5432 with encrypted storage, no public access, automated backups, and Multi-AZ by default. The JDBC URL uses sslmode=verify-full with the RDS CA bundle; refresh that bundle at least once a year.

Buckets. One bucket per logical name, named <prefix>-<logical name> with the same prefix in S3_BUCKET_PREFIX. Every bucket has versioning, server-side encryption, and a public access block, and denies requests without TLS. The proxy reads /static/ from the static-assets bucket through a VPC endpoint, with read access to objects only.

Secrets. DB_PASSWORD, ENCRYPTION_KEY, and ADMIN_INITIAL_PASSWORD are generated, stored in AWS Secrets Manager, and injected as container secrets. Only migrate receives ADMIN_INITIAL_PASSWORD.

Load balancer and TLS. An HTTPS listener with a certificate for the MSR host name forwards to the proxy on port 80. The target health check is /csconsole/api/health. A listener rule answers 404 for /csconsole/api/webhook/s3, the storage webhook, which AWS S3 never calls. Put rate limits on the sign-in and token endpoints /csconsole/login, /csconsole/api/auth/login, /csconsole/api/oauth2/as/token, and /csconsole/api/oauth2/register.

Required inputs. The public host name of MSR, the DNS zone that contains it, an email address for the alarms, and the modules to turn on (forms, portal, MCP, DocuSign).

What a deploy does

A deploy builds and pushes the app and proxy images, registers a new task revision, and replaces the task: the old task stops before the new one starts. A circuit breaker rolls back a deploy that does not become healthy, and the deploy checks that /csconsole/api/health reports the new build.

  • Never run more than one task. Redis lives in the task, so two tasks would not share sessions or cache invalidations.
  • A deploy causes a short outage and signs everyone out. Everything else held in Redis is lost too: pending delegated-connection prompts, MCP client state, delegated token state, and caches. AI View and portal users lose in-flight connection flows.
  • Module state comes from the task environment variables. Change the enabled modules in the deployment, not in the UI.
  • Database migrations are additive, so the previous image still runs against the migrated database and a rollback is possible.

After the first deploy, sign in to /csconsole/management as admin with ADMIN_INITIAL_PASSWORD and change the password. Check that the enabled modules are active, restart the task, and check again.

Kubernetes

MSR can be installed with the Module Suite Runtime Helm chart. The chart deploys the app and the proxy as separate Deployments that scale independently, terminates TLS at the Ingress, keeps sessions and cache in Redis, and runs database migrations as a Helm hook Job. It does not create PostgreSQL, Redis, or object storage; point it at managed services.

flowchart TD
    Start["helm install or helm upgrade"] --> Job["Migration Job (pre-install, pre-upgrade hook)"]
    Job -->|"succeeds"| Deploy["App and proxy Deployments"]
    Job -->|"fails"| Abort["Install or upgrade aborts"]
    Deploy --> Ingress["Services and Ingress"]
    Ingress --> Policies["HPA, PodDisruptionBudgets, NetworkPolicies"]
    Policies --> Gateway["AI Gateway bootstrap Job (post-install, when enabled)"]

The migration Job runs once per install or upgrade, before the Deployments are updated, so app pods start only when the database is at the current version.

Create the secrets the chart reads, then install:

kubectl create secret generic csr-postgres -n <namespace> \
  --from-literal=DB_USERNAME=<user> --from-literal=DB_PASSWORD=<password>
kubectl create secret generic <encryption-secret> -n <namespace> \
  --from-literal=ENCRYPTION_KEY="$(openssl rand -base64 32)"
helm install <release> <chart> -f my-values.yaml -n <namespace>

Set encryption.existingSecret in your values to the name of the encryption secret.

Main values

Value Description Default
image.app.repository, image.app.tag App image ghcr.io/answer-modules/msr-app
image.proxy.repository, image.proxy.tag Proxy image ghcr.io/answer-modules/msr-proxy
app.replicas, proxy.replicas Replicas when autoscaling is off 2, 2
app.resources, proxy.resources Requests and limits App 500m/1Gi to 2/2Gi; proxy 100m/128Mi to 500m/256Mi
app.javaOpts JAVA_OPTS of the app -XX:MaxRAMPercentage=75
app.server.shutdownGraceSeconds, app.quartzWaitForJobs, app.terminationGracePeriodSeconds Graceful shutdown. Keep them consistent, or in-flight requests and scheduled jobs are killed when a pod is rotated 30, true, 40
postgres.jdbcUrl JDBC URL of the external PostgreSQL
postgres.existingSecret Secret with DB_USERNAME and DB_PASSWORD csr-postgres
encryption.existingSecret Secret with ENCRYPTION_KEY Empty: delegated connections are disabled
delegation.publicBaseUrl Sets DELEGATION_PUBLIC_BASE_URL Empty: the issuer URL
redis.url Sets REDIS_URL
redis.existingSecret Secret with a REDIS_URL key, for Redis with authentication Empty
redis.sentinel.master, redis.sentinel.nodes Redis Sentinel. Wins over redis.url Empty
redis.readinessFailureThreshold Consecutive Redis failures before a pod reports not ready 3
s3.endpoint Sets S3_ENDPOINT. Empty for native AWS S3
s3.bucket, s3.region, s3.prefix Set S3_BUCKET, S3_REGION, S3_PREFIX
s3.existingSecret Secret with S3_ACCESS_KEY and S3_SECRET_KEY. Empty uses the service account role
s3.assetsBucket, s3.resourceBucket, s3.formRemoteBucket Set ASSETS_S3_BUCKET, RESOURCE_S3_BUCKET, FORM_REMOTE_S3_BUCKET Empty: application defaults
s3.backendHost, s3.backendProto, s3.assetsPathPrefix Where the proxy fetches /static/. Leave assetsPathPrefix empty for a virtual-hosted AWS bucket backendProto: https
formRemote.enabled Sets FORM_REMOTE_ENABLED=true false
serviceAccount.create, serviceAccount.annotations Service account, for example with an IAM role annotation for S3 access false
libExt.enabled, libExt.initImage, libExt.sourcePath An init container copies the OpenText SDK JARs from your image into /app/lib/ext false, /jars
otel.enabled, otel.endpoint OpenTelemetry export false
flyway.enabled The migration Job true

Module flags in the chart

The chart sets only FORM_REMOTE_ENABLED, through formRemote.enabled. It does not set PORTAL_ENABLED, MCP_ENABLED, DOCUSIGN_ENABLED, PORTAL_SHELL_S3_BUCKET, MCP_S3_BUCKET, or DOCUSIGN_WEBHOOK_BUCKET.

Ingress and TLS

The Ingress routes the host to the proxy Service on port 80.

Value Description Default
ingress.enabled, ingress.className, ingress.host Ingress, class, and host true, nginx
ingress.tls.enabled, ingress.tls.issuerRef cert-manager issues the TLS Secret <host>-tls from this issuer true
ingress.annotations Controller annotations nginx.ingress.kubernetes.io/proxy-body-size: "11m"

The app accepts requests up to 11 MB, and the NGINX Ingress default is 1 MB, so keep the body-size annotation. When a load balancer terminates TLS with its own certificate, set ingress.tls.enabled to false:

ingress:
  className: "alb"
  host: "msr.example.com"
  tls:
    enabled: false
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/certificate-arn: "<certificate-arn>"
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443},{"HTTP":80}]'
    alb.ingress.kubernetes.io/ssl-redirect: "443"
networkPolicy:
  enabled: true
  albCIDRs:
    - "<public-subnet-cidr-a>"
    - "<public-subnet-cidr-b>"

Autoscaling, disruption budgets, and network policies

Value Description Default
hpa.app.enabled, hpa.app.minReplicas, hpa.app.maxReplicas HorizontalPodAutoscaler for the app true, 2, 10
hpa.app.metrics Scaling metrics CPU at 70% average utilization
hpa.proxy.enabled HorizontalPodAutoscaler for the proxy false
pdb.app.minAvailable, pdb.proxy.minAvailable PodDisruptionBudgets 1, 1
networkPolicy.enabled NetworkPolicies for the app and the proxy true
networkPolicy.albCIDRs Load balancer subnets allowed to reach the proxy. Empty allows the ingress-nginx namespace Empty

The app NetworkPolicy admits port 8080 only from the proxy pods. The management port 9190 stays open to every source, for the kubelet probes and metrics scrapers. App probes use /csconsole/actuator/health/readiness and /csconsole/actuator/health/liveness on port 9190.

Running more than one replica

Sessions, caches, and script-cache invalidations go through Redis, so every app replica must use the same Redis, and a user can be served by any replica. Readiness includes Redis: when Redis is unreachable, every app pod reports not ready at once. Use a highly available Redis, such as Sentinel, for production.

AI Gateway

The optional AI Gateway is an MCP backend that runs as a private service reachable only from the app pods. It is never exposed to the internet. Its values are under obot:

Value Description Default
obot.enabled Deploys the AI Gateway false
obot.image.repository, obot.image.tag AI Gateway image ghcr.io/answer-modules/obot-am-platform
obot.hostname, obot.basePath Internal host name and the public base path basePath: /ai-gateway
obot.platformLoginURL MSR sign-in URL for unauthenticated browsers
obot.dsn Connection string of the AI Gateway's own PostgreSQL
obot.bootstrapToken, obot.sharedSecret One-time setup token and the secret that signs calls between the AI Gateway and MSR
obot.existingSecret Secret with OBOT_SERVER_DSN, OBOT_BOOTSTRAP_TOKEN, and AM_PLATFORM_OBOT_AUTH_SHARED_SECRET, instead of the values above Empty
obot.persistence.enabled, obot.persistence.size Persistent volume true, 8Gi
obot.networkPolicy.enabled, obot.networkPolicy.databaseCIDRs Admits only app pods; egress to DNS, the app, its database, and public addresses true
obot.bootstrap.enabled Post-install Job that configures the authentication provider and role mappings true

The AI Gateway runs as one replica.

Upgrades and database migrations

In multi-node mode, database migrations run in a separate step before the new app version serves traffic: the migrate container on AWS ECS and the migration Job in Kubernetes.

Build or pull the new app and proxy images, then recreate the services:

docker compose build
docker compose up -d

Deploy the new images. The task is replaced; migrate applies the migrations before app starts. Users are signed out. If the new task does not become healthy, the circuit breaker rolls back to the previous revision.

Set the new image.app.tag and image.proxy.tag, then upgrade:

helm upgrade <release> <chart> -f my-values.yaml -n <namespace>

The migration Job runs first, then the Deployments are replaced one pod at a time. To go back, use helm rollback <release> -n <namespace>.

Deployment troubleshooting

Problem Cause and fix
helm install or helm upgrade fails and the migration Job shows an error The Job cannot reach the database or the credentials are wrong. Check the Job logs, postgres.jdbcUrl, and the DB_USERNAME and DB_PASSWORD in the database secret. Delete the failed Job and upgrade again
The load balancer returns 502 The proxy pods are not ready yet, or the proxy NetworkPolicy blocks the load balancer. Set networkPolicy.albCIDRs to the load balancer subnets
Uploads are rejected with 413 The Ingress limits the request body. Keep the proxy-body-size annotation at 11m
Every app pod is not ready at the same time Redis is unreachable. Check REDIS_URL or the Sentinel settings
Content Server services are disabled at startup The OpenText SDK JARs are missing from /app/lib/ext. Mount them (Docker Compose) or enable libExt (Kubernetes)
Delegated connections are disabled ENCRYPTION_KEY is not set
Stored connections or OAuth client secrets cannot be read ENCRYPTION_KEY changed. Restore the original key
OAuth clients or the AI Gateway reject tokens OAUTH2_ISSUER_URL is not the public URL plus /csconsole
Users are signed out after a deploy on AWS ECS A deploy replaces the task and clears all sessions
A deactivated module is active again after a restart Module state comes from the environment variables. Change the module flag in the deployment

Error responses

A public error response does not expose an internal failure, so do not build client logic on the error text of a public response.

For portal pages, expect these responses:

Situation Response
Anonymous visitor fails the page's Access List Redirect to /portal/login?returnTo=…
Signed-in user fails the page's Access List 403
Portal API called without a session 401, which the host does not turn into a redirect
Delegated connection needs to be connected 409, which opens a Connect prompt

Troubleshooting

Problem Cause and fix
A region is empty The user fails the Access List of the region's initial component. Check the component's Access List
An AI View region does not appear The form is PIN-protected and the PIN is not verified, or the form is expired, inactive, or not visible to the user
A page fails to render The controller pins a component version that was deleted, or the layout and the page define the same region
Content Server calls fail from the browser Add the MSR origin to the CORS allowed origins in Content Server administration, so the Authorization header is admitted
Content Server calls return 409 The user has not connected the page's delegated connection. Complete the Connect prompt
A component still shows old code The page pins an older version, or the new version is not active. Activate the version you want
sub.attachments is empty or attachment downloads are refused The MSR user of the msr profile does not have FORM_MANAGE. Use a user with FORM_MANAGE
A /static/ file is missing a folder header The proxy keeps folder headers for 60 seconds. Wait a minute after you change them