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 withAccess-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.
requiredPermissionsincomponent.jsonis only a user-experience gate.- Portal administration requires the
PORTAL_MANAGEpermission. 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
msrprofile on Content Server whose MSR user has FORM_MANAGE. Without it,sub.attachmentsis 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
csadmingetsS3_BUCKET_PREFIX.listBucketsshows only prefixed buckets, under their logical names. - The
awsextension 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 |