ADR-0006 Language pack installation via the CORS proxy and Moodle's lang_installer¶
- Status: Accepted
- Date: 2026-06-08
Context and Problem¶
Moodle core bundles only the English language (dirroot/lang/en). Every other language is a
separate pack that must be downloaded into dataroot/lang/<code>. Playground users need to
install languages (e.g. Spanish) both interactively and from blueprints.
Moodle's own importer (admin/tool/langimport, backed by the lang_installer class in
lib/componentlib.class.php) downloads packs from download.moodle.org/langpack. In the
playground that download runs inside PHP/WASM and is translated to a browser fetch() by
@php-wasm's tcpOverFetch bridge, which is configured with phpCorsProxyUrl
(github-proxy.exelearning.dev).
Two things blocked it:
- The proxy only authorized zip-like paths.
tool_langimportfirst fetches the langpack indexlanguages.md5(a non-zip path), which the proxy'sisSupportedGenericProxyUrl(looksLikeZipUrl && isAllowlistedProxyHost) rejected with400. The whole flow failed before reaching the.zip. - A presumed Firefox/Safari limitation. The project documents that outbound WASM HTTP
fails on Firefox/Safari (
errno 23, handled bycrash-recovery.js). This suggested native install could only ever be Chromium-only.
On investigation (tcp-over-fetch-websocket.ts), the WASM-network limitation only applies to
requests with a streaming request body (duplex: 'half', used for POST/PUT uploads).
Language pack downloads are plain GET requests, so they carry no streaming body and work in
every browser. The only real blocker was the proxy's zip-only rule.
Options Considered¶
-
Option 1 —
installLanguagePackstep that downloads + extracts the ZIP client-side. Fetch the pack in JS (workerfetch()through the proxy) and unzip it intodataroot/lang. Works cross-browser but reimplements what Moodle already does (parent-language resolution, version selection, index parsing) and only covers the blueprint path, not the admin UI. -
Option 2 — Authorize Moodle langpack paths in the proxy and use Moodle's own
lang_installer. Allow/langpack/paths (incl.languages.md5) ondownload.moodle.organdpackaging.moodle.org. Nativeadmin/tool/langimportthen works directly, and the blueprint step / auto-install are thin wrappers (new lang_installer($codes))->run()). -
Option 3 — Allow CORS only and rely on the admin UI. No blueprint automation; users must install languages by hand every session (state is ephemeral).
Decision¶
Option 2. Authorize Moodle langpack paths through the github-proxy worker and build the
language features on top of Moodle's own lang_installer:
- Proxy (
scripts/github-proxy-worker.js): addpackaging.moodle.organddownload.moodle.orgtoisAllowlistedProxyHost(zip downloads), and addisMoodleLangpackUrl()(authorizes any/langpack/path on those two hosts, regardless of extension, solanguages.md5passes). The SSRF guard and per-redirect re-validation still run for every hop (download.moodle.org302-redirects topackaging.moodle.org). installLanguagePackblueprint step (src/blueprint/steps/moodle-language.js): validates language codes (/^[a-z][a-z0-9_]*$/) and callslang_installerfor one or more packs, optionally setting the site default (setDefault).- Auto-install (
src/runtime/bootstrap.js,runLanguageAutoInstall): when the configured site language ($CFG->lang) is not English, install it automatically during boot. Idempotent (skips if the pack is already on disk — it persists indataroot) and non-fatal (a download failure falls back to English strings).
Consequences¶
Positive¶
- Works in every browser — language downloads are GET requests, so they avoid the
duplex: 'half'WASM-network limitation that affects Firefox/Safari. - Native admin flow works — Site administration → Language → Language packs installs directly, no custom code.
- Minimal code — the step and auto-install are ~3 lines of PHP each; Moodle handles parent-language resolution, version selection and extraction.
- Set-and-forget —
locale: "es"in config/blueprint auto-installs the pack on boot.
Negative / Risks¶
- Broader proxy surface — the proxy now forwards non-zip paths on the two Moodle hosts.
Scoped to
/langpack/paths only; the SSRF guard and redirect re-validation still apply. - Boot-time network dependency — a fresh boot with a non-English site language makes a network call. It is non-fatal and idempotent across reloads, but adds latency on first boot and depends on the proxy being reachable.
- Relies on
tcpOverFetchproxy routing — ifphpCorsProxyUrlis unset or upstream@php-wasmchanges how the CORS proxy is applied, native install reverts to a direct (CORS-blocked) fetch.
Implementation Notes¶
Files modified / added¶
scripts/github-proxy-worker.js—isAllowlistedProxyHost(+2 hosts), newisMoodleLangpackUrl()wired intoisSupportedGenericProxyUrl. Deployed to thegithub-proxyandzip-proxyworkers; mirrored to the sibling playground repos.tests/shared/github-proxy-worker.test.js— langpack ZIP, redirect, andlanguages.md5cases.src/blueprint/steps/moodle-language.js— newinstallLanguagePackhandler.src/blueprint/php/helpers.js—phpInstallLanguagePacks()generator.src/blueprint/steps/index.js,src/blueprint/schema.js— register the step.src/runtime/bootstrap.js—runLanguageAutoInstall()invoked before blueprint execution.tests/blueprint/language-pack.test.js,tests/blueprint/steps.test.js— coverage.
Follow-up — fixes after end-to-end verification (2026-06-09)¶
The proxy allowance shipped, but end-to-end the language never actually applied. Loading a
Spanish blueprint left the UI in English. Investigation found the proxy and networking were
fine (the worker already defaults corsProxyUrl to config.phpCorsProxyUrl, so
tcpOverFetch routes langpack GETs through github-proxy even without a ?phpCorsProxyUrl=
URL param). Three separate problems, none of them networking, blocked it:
lang_installerfailed its requisites check.component_installer::check_requisites()(lib/componentlib.class.php) returnswrongdestpathand throwsinstaller_requisites_check_failedunless$CFG->dataroot/langalready exists. On a fresh WASM runtime that directory does not exist, so the install aborted in ~45ms before any download — both forrunLanguageAutoInstalland theinstallLanguagePackstep. (Manual install "worked" only because browsing the admin Language UI had already created the dir.) Fix: callmake_upload_directory('lang')before invokinglang_installerin both code paths.- The snapshot boot never applied
installMoodle.options.locale. Only the full CLI installer (createInstallRunnerPhp) writes$CFG->lang; the pre-built snapshot path skips it, leaving$CFG->lang = 'en'. SorunLanguageAutoInstallread'en'and skipped, and a locale-only blueprint never localized. Fix:runSiteLanguageConfigure()persists the configured locale (set_config('lang', …)) just beforerunLanguageAutoInstall. - Setting the site default was not enough for the logged-in admin. The install snapshot
bakes
mdl_user.admin.lang = 'en', and a logged-in user'slangoverrides$CFG->lang. Since the playground auto-logs-in as admin, the dashboard stayed English even afterset_config('lang','es'). Fix: when applying a new default (both inrunSiteLanguageConfigureand thesetDefaultbranch ofphpInstallLanguagePacks), repoint users still on the previous default with$DB->set_field_select('user', 'lang', <new>, 'lang = ? AND deleted = 0', [<old>]). Users provisioned by later blueprint steps inherit the new default at creation.
Verified end-to-end: a blueprint with locale: "es" + installLanguagePack {setDefault:true}
boots with Installed site language pack 'es', and the auto-logged-in admin dashboard renders
in Spanish (<html lang="es">, "Área personal", "Administración del sitio").
Additional files modified¶
src/runtime/bootstrap.js—make_upload_directory('lang')inrunLanguageAutoInstall; newrunSiteLanguageConfigure()invoked before the auto-install.src/blueprint/php/helpers.js—make_upload_directory('lang')+ repoint-users logic in thesetDefaultbranch ofphpInstallLanguagePacks.tests/blueprint/language-pack.test.js— asserts for the dir creation and user repointing.
Review Criteria¶
- If
@php-wasmchangestcpOverFetch's CORS-proxy handling or a future Moodle changes thelang_installerAPI or langpack URL scheme, re-verify both the native flow and the step. - If the proxy's allowlist model is reworked, fold
isMoodleLangpackUrlinto the new model. - If boot latency from auto-install becomes a concern, consider gating it behind an explicit
config flag instead of inferring from
$CFG->lang.