This is a PG Renderer derived from the WeBWorK2 codebase
mkdir volumes
mkdir container
git clone https://github.com/openwebwork/webwork-open-problem-library volumes/webwork-open-problem-library
git clone --recursive https://github.com/openwebwork/renderer container/
docker build --tag renderer:1.0 ./container
docker run -d \
--rm \
--name standalone-renderer \
--publish 3000:3000 \
--mount type=bind,source="$(pwd)"/volumes/webwork-open-problem-library/,target=/usr/app/webwork-open-problem-library \
--env MOJO_MODE=development \
renderer:1.0If you have non-OPL content, it can be mounted as a volume at /usr/app/private by adding the following line to the
docker run command:
--mount type=bind,source=/pathToYourLocalContentRoot,target=/usr/app/private \A default configuration file is included in the container, but it can be overridden by mounting a replacement at the
application root. This is necessary if, for example, you want to run the container in production mode.
--mount type=bind,source=/pathToYour/renderer.conf,target=/usr/app/renderer.conf \If using a local install instead of docker:
- Clone the renderer and its submodules:
git clone --recursive https://github.com/openwebwork/renderer - Enter the project directory:
cd renderer - Install Perl dependencies listed in Dockerfile (CPANMinus recommended)
- clone webwork-open-problem-library into the provided stub ./webwork-open-problem-library
git clone https://github.com/openwebwork/webwork-open-problem-library ./webwork-open-problem-library
- copy
renderer.conf.disttorenderer.confand make any desired modifications - copy
conf/pg_config.ymltolib/PG/pg_config.ymland make any desired modifications - install third party JavaScript dependencies
cd public/npm cicd ..
- install PG JavaScript dependencies
cd lib/PG/htdocsnpm ci
- start the app with
morbo ./script/rendererormorbo -l http://localhost:3000 ./script/rendererif changing root url - access on
localhost:3000by default or otherwise specified root url
- point your browser at
localhost:3000 - select an output format (see below)
- specify a problem path (e.g.
Library/Rochester/setMAAtutorial/hello.pg) and a problem seed (e.g.1234) - click on "Load" to load the problem source into the editor
- render the contents of the editor (with or without edits) via "Render contents of editor"
- click on "Save" to save your edits to the specified file path
Configuration lives in renderer.conf (copied from renderer.conf.dist during build). All key settings can also be overridden via environment variables, which take precedence over the config file — this is the recommended approach for Docker deployments.
| Setting | Env Override | Description |
|---|---|---|
SITE_HOST |
SITE_HOST |
Public-facing origin URL. Used as <base href> in rendered HTML and as issuer/audience in JWTs. Must match what the end user's browser sees. |
baseURL |
baseURL |
Path prefix when mounted at a subpath (e.g. renderer for https://example.com/renderer/). If set to an absolute URL, overrides SITE_HOST for asset references. Leave empty when hosting at root. |
formURL |
formURL |
Where answer forms POST to. Defaults to {SITE_HOST}{baseURL}/render-api. Set to an absolute URL for MITM deployments. |
problemJWTsecret |
problemJWTsecret |
Shared secret for encrypting render configuration JWTs. Must match any service that creates problem tokens. |
webworkJWTsecret |
webworkJWTsecret |
Shared secret for session state JWTs (attempt history, scores). |
CORS_ORIGIN |
— | Allowed origin for CORS headers. Set to the embedding site's origin for iframe deployments. * is insecure. |
STRICT_JWT |
STRICT_JWT |
Entry gate. When 1, ungrounded requests are rejected with 401 — the instance only serves callers arriving with a problemJWT, challengeJWT, sessionJWT, or X-Peer-Signature. When 0 (default), ungrounded requests are admitted. Orthogonal to answerJWT emission, which is always gated by upstream-JWT presence. |
SELF_MINT_DISABLED |
SELF_MINT_DISABLED |
When unset (default), an admitted ungrounded request is wrapped in a self-minted problemJWT so the next render can flow through the standard sessionJWT round-trip without the consumer re-mailing isInstructor, sessionID, etc. Set to 1 for raw-passthrough deployments. Self-minted JWTs cannot ground answerJWT emission. |
FULL_APP_INSECURE |
— | Enables editor UI, OPL browser, and file management routes in production mode. Always available in development mode. |
STATIC_EXPIRES |
— | Cache-Control max-age (seconds) for static assets under /webwork2_files/. |
The renderer was designed to support several integration patterns. The URL configuration (SITE_HOST, baseURL, formURL) and JWT architecture adapt to each.
The renderer serves problems directly to the user's browser. Simplest setup.
Browser ←→ Renderer
docker run -d -p 3000:3000 \
-e SITE_HOST=https://renderer.example.com \
rendererSITE_HOST is the renderer's own public URL. baseURL and formURL are empty (defaults). The browser loads rendered HTML and submits answers directly to the renderer.
A middleware sits between the student and the renderer. The student's browser talks to the proxy, which forwards render requests and intercepts answer submissions.
Browser ←→ Proxy ←→ Renderer
docker run -d -p 3000:3000 \
-e SITE_HOST=http://localhost:3000 \
-e baseURL=https://proxy.example.com/webwork/ \
-e formURL=https://proxy.example.com/webwork/render-api \
rendererbaseURLis absolute (the proxy's origin) — rendered HTML references assets through the proxyformURLis absolute — answer forms POST to the proxy, not the renderer directly- The proxy forwards render requests to the renderer's internal address and relays responses
The LMS and renderer are separate services. The student's browser communicates with both: the LMS issues a JWT, the browser loads the renderer in an iframe using that JWT, and the renderer reports scores back to the LMS asynchronously.
LMS (LibreTexts)
↗ (1. get JWT) ↖ (3. answerJWT callback)
Browser ——————————→ Renderer (iframe)
(2. render + submit via JWT)
- Student requests a problem from the LMS
- LMS issues a
problemJWTcontaining the render config and aJWTanswerURLpointing back at the LMS grading endpoint - Student's browser loads the renderer in an iframe, passing the
problemJWT - On answer submission, the renderer POSTs an
answerJWT(containing score + sessionJWT) to theJWTanswerURLfrom inside the token - LMS updates its gradebook; student can resume via
sessionJWTif the iframe closes
docker run -d -p 3000:3000 \
-e SITE_HOST=https://renderer.example.com \
-e CORS_ORIGIN=https://lms.example.com \
-e problemJWTsecret=<shared-with-LMS> \
-e webworkJWTsecret=<renderer-internal> \
rendererSITE_HOSTmust match the iframe'ssrcorigin (what the browser sees)CORS_ORIGINis the LMS origin (the iframe's parent)problemJWTsecretmust be shared between the LMS and rendererJWTanswerURLis embedded in the JWT by the LMS, not configured on the renderer- Student submit paths that produce an answerJWT require a valid
problemJWT(enforced automatically — no flag needed). Preview and browsing paths stay open.
Can be accessed by POST to {SITE_HOST}{baseURL}{formURL}.
By default, localhost:3000/render-api.
The bare minimum of parameters that must be included are:
- the code for the problem, so, ONE of the following (in order of precedence):
problemSource(raw pg source code, can be base64 encoded)sourceFilePath(relative to OPLLibrary/,Contrib/; or inprivate/)problemSourceURL(fetch the pg source from remote server)
- a "seed" value for consistent randomization
problemSeed(integer)
| Key | Type | Description | Notes |
|---|---|---|---|
| problemSource | string (possibly base64 encoded) | The source code of a problem to be rendered | Takes precedence over sourceFilePath. |
| sourceFilePath | string | The path to the file that contains the problem source code | Renderer will automatically adjust Library/ and Contrib/ relative to the webwork-open-problem-library root. Path may also begin with private/ for local, non-OPL content. |
| problemSourceURL | string | The URL from which to fetch the problem source code | Takes precedence over problemSource and sourceFilePath. A request to this URL is expected to return valid pg source code in base64 encoding. |
| problemSeed | number | The seed that determines the randomization of a problem |
ALL other request parameters are optional.
The defaults for these parameters are set in renderer.conf, but these can be overridden on a per-request basis.
| Key | Type | Default Value | Description | Notes |
|---|---|---|---|---|
| baseURL | string | '/' (as set in renderer.conf) |
the URL for relative paths | |
| formURL | string | '/render-api' (as set in renderer.conf) |
the URL for form submission |
Parameters that control the structure and templating of the response.
| Key | Type | Default Value | Description | Notes |
|---|---|---|---|---|
| language | string | en | Language to render the problem in (if supported) | affects the translation of template strings, not actual problem content |
| _format | string | 'html' | Determine how the response is structured ('html' or 'json') | usually 'html' if the user is directly interacting with the renderer, 'json' if your CMS sits between user and renderer |
| outputFormat | string | 'default' | Determines how the problem should be formatted | 'default', 'static', 'PTX', 'raw', or |
| displayMode | string | 'MathJax' | How to prepare math content for display | 'MathJax' or 'ptx' |
Control how the user is allowed to interact with the rendered problem.
Requesting outputFormat: 'static' will prevent any buttons from being included in the rendered output, regardless of the following options.
| Key | Type | Default Value | Description | Notes |
|---|---|---|---|---|
| hidePreviewButton | number (boolean) | false | "Preview My Answers" is enabled by default | |
| hideCheckAnswersButton | number (boolean) | false | "Submit Answers" is enabled by default | |
| showCorrectAnswersButton | number (boolean) | isInstructor |
"Show Correct Answers" is disabled by default, enabled if isInstructor is true (see below) |
Control what is shown to the user: hints, solutions, attempt results, scores, etc.
| Key | Type | Default Value | Description | Notes |
|---|---|---|---|---|
| permissionLevel | number | 0 | DEPRECATED. Use isInstructor instead. |
|
| isInstructor | number (boolean) | 0 | Is the user viewing the problem an instructor or not. | Used by PG to determine if scaffolds can be allowed to be open among other things |
| showHints | number (boolean) | 1 | Whether or not to show hints | |
| showSolutions | number (boolean) | isInstructor |
Whether or not to show the solutions | |
| hideAttemptsTable | number (boolean) | 0 | Hide the table of answer previews/results/messages | If you have a replacement for flagging the submitted entries as correct/incorrect |
| showSummary | number (boolean) | 1 | Determines whether or not to show a summary of the attempt underneath the table | Only relevant if the Attempts Table is shown hideAttemptsTable: false (default) |
| showComments | number (boolean) | 0 | Renders author comment field at the end of the problem | |
| showFooter | number (boolean) | 0 | Show version information and WeBWorK copyright footer | |
| includeTags | number (boolean) | 0 | Includes problem tags in the returned JSON | Only relevant when requesting _format: 'json' |
There are three JWT structures that the Renderer uses, each containing its predecessor:
- problemJWT
- sessionJWT
- answerJWT
This JWT encapsulates the request parameters described above, under the API heading. Any value set in the JWT cannot be
overridden by form-data. For example, if the problemJWT includes isInstructor: 0, then any subsequent interaction with
the problem rendered by this JWT cannot override this setting by including isInstructor: 1 in the form-data.
This JWT encapsulates a user's attempt on a problem, including:
- the text and LaTeX versions of each answer entry
- count of incorrect attempts (stopping after a correct attempt, or after
showCorrectAnswersis used) - the problemJWT
If stored (see next), this JWT can be submitted as the sole request parameter, and the response will effectively restore the users current state of interaction with the problem (as of their last submission).
If the initial problemJWT contains a value for JWTanswerURL, this JWT will be generated and sent to the specified URL.
The answerJWT is the only content provided to the URL. The renderer is intended to to be user-agnostic. It is
recommended that the JWTanswerURL specify the unique identifier for the user/problem combination. (e.g. JWTanswerURL: 'https://db.yoursite.org/grades-api/:user_problem_id')
For security purposes, this parameter is only accepted when included as part of a JWT.
This JWT encapsulates the status of the user's interaction with the problem.
- score
- sessionJWT
The goal here is to update the JWTanswerURL with the score and "state" for the user. If you have uses for additional
information, please feel free to suggest as a GitHub Issue.
The renderer is intentionally dumb: it renders what it's told with the parameters it's given. It does not enforce
policy about who is allowed to request what — gating which inputs reach /render-api is the deployer's responsibility.
None of the surfaces below are renderer bugs; they're parameters the renderer trusts the caller to set responsibly.
These output formats include answers.correct_ans for every slot in the response, with no internal gate:
| Parameter | Value | Where the leak happens |
|---|---|---|
outputFormat |
raw |
lib/WeBWorK/FormatRenderedProblem.pm:180 — emits the entire $rh_result as JSON |
outputFormat |
ptx |
lib/WeBWorK/FormatRenderedProblem.pm:159 — builds answerhashXML from answers |
The json output format is internally gated (FormatRenderedProblem.pm:289 — only includes answers when
isInstructor=1), so it's safe as long as isInstructor is constrained.
| Parameter | Risk if URL-injectable |
|---|---|
isInstructor |
Toggles answer inclusion in outputFormat=json; also flips defaults for showSolutions etc. |
showCorrectAnswers |
Renders the correct-answer reveal directly in the result summary |
problemSourceURL |
Redirects source-fetch to an arbitrary URL (deployer should pin to their own OPL/library) |
problemSource |
Lets the caller execute arbitrary PG; intended in the peer-signed lane, hostile in any other |
JWTanswerURL |
Where answerJWTs (score + sessionJWT) are POSTed — the renderer already gates this internally to JWT-only since the consequence (signing scores to an attacker-controlled endpoint) is severe. The other rows are not internally gated. |
In rough order of increasing decoupling:
- JWT claim locking — the issuer stamps sensitive claims into the problemJWT. The renderer's rule that "JWT claims override form-data" (see ProblemJWT) prevents URL injection of those keys. Simple to deploy, but ties the policy to the issuer's code — a renderer shared by multiple issuers can't trust them uniformly.
- Reverse-proxy filter — Caddy / nginx / CloudFront / WAF strips or rewrites disallowed query params before they
reach
/render-api. Decouples policy from the issuer; the deployer owns it. Composes with strategy 1. - Bearer-token gating on
/render-api— wrap the renderer behind a service-mesh auth layer so only known callers can reach it. Common in multi-tenant or API-gateway deployments. - Network isolation — renderer accessible only on a private network from a trusted gateway service. Direct browser-to-renderer iframe flows then need a signed-URL pattern (the gateway mints, the renderer verifies).
These are not mutually exclusive; pick the combination that matches your trust model. A single-tenant LMS deployment might use only (1). A multi-tenant CDN-fronted deployment likely uses (2) and (3). A locked-down SSR-from-backend deployment can use (4) with no JWTs at all.
