Content origin
Uploaded packages (SCORM, cmi5, Adapt builds, LiaScript, Interactive) run arbitrary third-party JavaScript.
ulams serves them from a content origin: a host per tenant that holds nothing but package
files. It has no API routes, accepts and sets no cookies, and sends its own Content Security
Policy. The full design is in
api/docs/content-origin.md;
this page covers what an operator sets up.
Two modes
Section titled “Two modes”| Mode | Example (app on *.lms.example.com) |
Status |
|---|---|---|
| Separate registrable domain | {slug}.example-content.net |
Strongest. Package code is cross-site with the app, so browsers keep their cookies and process isolation away from it. |
| Same-site subdomain | {slug}.content.lms.example.com (for ulams.app: *.content.ulams.app) |
Supported with the mitigations below. Chosen for the hosted ulams.app service (product owner, 2026-10-09). |
On a same-site content origin, package code is same-site with the application. Browsers then send
SameSite=Lax and Strict cookies on its requests to the app’s hosts (so SameSite gives no CSRF
protection against it), it can set cookies for the parent domain (cookie tossing, session
fixation), and it shares site-level process isolation. ulams ships these mitigations, and they are
also on in the separate-domain mode:
- Host-only cookies. Every session cookie uses the
__Host-prefix in production (Secure,Path=/, never aDomain), so a sibling subdomain cannot overwrite them: the learner front and studio (__Host-ulams_session,__Host-ulams_author), and the API’s Laravel session andXSRF-TOKEN. Over plain http in development the prefix is dropped (ULAMS_COOKIE_FALLBACK_PREFIX,SESSION_COOKIE_PREFIX). Sessions created before the rollout used unprefixed names; ask users to sign in again or clear those cookies. - Exact-Origin checks. Every
POST,PUT,PATCHandDELETEto the learner front and to the API must come from the tenant’s own app origins (FRONTEND_URL,ADMIN_URL,APP_URL, plusTRUSTED_ORIGINS). A content origin,Origin: nullor aSec-Fetch-Siteother thansame-origingets403. Only routes authenticated by a non-ambient credential are exempt: the tracking-token endpoints, LTI, payment callbacks. The API authenticates with bearer tokens only (no cookie-only endpoint exists). CORS allow-lists never include content origins. - Sandboxed frames. Players are framed with
sandboxandreferrerpolicy="no-referrer". SCORM, cmi5, Adapt, LiaScript and H5P still needallow-same-origin(the SCO findswindow.APIthrough its parent frame; LiaScript uses a Worker, storage and a service worker; H5P calls its service with fetch), so for them mitigations 1, 2 and 4 carry the protection. - Response headers. Content files send the CSP,
X-Content-Type-Options: nosniff,Cross-Origin-Opener-Policy: same-originandCross-Origin-Resource-Policy: cross-origin. The app hosts sendCross-Origin-Resource-Policy: same-originon sensitive JSON (API JSON,/bff/*,/studio/api/*), so content pages cannot embed it.
Configure it
Section titled “Configure it”| Where | Setting | Example |
|---|---|---|
api |
TENANCY_CONTENT_HOST (tenants; written as CONTENT_ORIGIN into each env file) |
{slug}.example-content.net |
api |
CONTENT_ORIGIN (platform) |
https://platform.example-content.net |
api |
TRUSTED_ORIGINS (optional extra app origins allowed to write to the API) |
https://custom-front.example.com |
| DNS | Wildcard on the content domain | *.example-content.net, platform.example-content.net |
| Same-site mode | TENANCY_CONTENT_HOST and ULAMS_CONTENT_DOMAIN on a subdomain of the app domain |
{slug}.content.lms.example.com, content.lms.example.com |
| Caddy | One site per content host, using the content_origin snippet |
see the example Caddyfile |
Existing tenants pick up a changed TENANCY_CONTENT_HOST on the next
php artisan ulams:tenant:sync-env (it runs on every api start). A tenant without
CONTENT_ORIGIN falls back to the legacy SCORM player on the API origin.
Set up a separate content domain
Section titled “Set up a separate content domain”The strongest mode needs a second registrable domain. The domain below is a placeholder
(example-content.net) until the real one is chosen; use yours everywhere it appears.
-
Register the domain. Do not use a subdomain of the application’s registrable domain: that is the same-site mode.
-
Create DNS records pointing at the server (A and AAAA, or a CNAME to your load balancer):
Record Purpose *.example-content.netOne content origin per tenant ( <slug>.example-content.net)platform.example-content.netThe platform’s own content origin storage.example-content.netBrowser-facing object storage, only with the bundled MinIO A wildcard record does not cover the bare domain; it is not used.
-
Set
ULAMS_CONTENT_DOMAIN=example-content.netin the.envof the production example. The compose file turns it intoTENANCY_CONTENT_HOST={slug}.example-content.netandCONTENT_ORIGIN=https://platform.example-content.neton the API, and the Caddyfile into the*.example-content.net,platform.andstorage.sites. -
Certificates: with the default on-demand TLS, Caddy issues one for each tenant content host the first time it is requested, after the tenant check. With wildcard certificates (DNS-01) add
*.example-content.netto the wildcard list. See DNS and TLS. -
Apply it to existing tenants and check the result:
Terminal window docker compose up -ddocker compose exec api php artisan ulams:upgradecurl -sI https://<slug>.example-content.net/scorm/x | head -5ulams:tenant:sync-env(a step ofulams:upgrade, and run at start) rewrites each tenant’sCONTENT_ORIGIN. Thecurlshould answer from Caddy with the content headers and404(no such package), not5xxand not a certificate error.
H5P is not served from the content origin; see H5P in production.
What the proxy must do
Section titled “What the proxy must do”The content_origin snippet, copied from the development Caddyfile with HTTPS hosts:
- answers only
GETandHEADfor/scorm/*,/cmi5/*,/adapt/*,/liascript/*and/interactive/*, and 404 for everything else; - rewrites them to
GET /api/content/...on the tenant API (php-fpm) withHost/X-Forwarded-Hostset to the tenant API host andX-Ulams-Content-Origin: 1; - drops
CookieandAuthorizationon the way in andSet-Cookieon the way out; - sends
Content-Security-Policyonly when the API did not (header ?Content-Security-Policy; the API sets the policy of every/interactive/*file itself, from the package version’s manifest, see Interactive), and otherwise (scripts allowed,connect-srclimited to the tenant API,frame-ancestorslimited to the tenant front and admin,form-action 'none'),X-Content-Type-Options: nosniff,Cross-Origin-Opener-Policy: same-origin,Cross-Origin-Resource-Policy: cross-originandReferrer-Policy: no-referrer.
Interactive packages are the exception to the shared policy: the frame that plays them is sandboxed
without allow-same-origin, so the package runs in an opaque origin, and the API answers each file
with a policy of its own (no 'unsafe-eval', connect-src 'self' unless the tenant allows the
manifest’s network list, frame-ancestors limited to the tenant front and admin). Keep
/interactive/* in the proxy’s package matcher and do not override the header there.
The API sites strip X-Ulams-Content-Origin from client requests, and the API answers 404 to
/api/content/... without it, so package HTML never runs on the API origin.
Serving package files straight from the bucket through a CDN with the same headers also works; the API route is the simple default because it covers local and bucket disks alike.