Secure opaque-origin viewer, SCORM/xAPI bridge and HTTP editor preview v2#80
Draft
erseco wants to merge 81 commits into
Draft
Secure opaque-origin viewer, SCORM/xAPI bridge and HTTP editor preview v2#80erseco wants to merge 81 commits into
erseco wants to merge 81 commits into
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…ice-worker limitation
…ermissions-Policy
… secure->legacy (watchdog)
…DEC-0059 Route A)
… not the site origin
…g, content_headers, bridge re-extract)
…load fails On a host whose service worker cannot serve an opaque-origin iframe (e.g. the PHP-WASM Moodle Playground), the token URL falls through to a 404 and the in-iframe shim never announces 'ready'. The relay watchdog already reveals the "blocked by security configuration" notice instead of degrading to same-origin, but it waited a flat 8s, during which the iframe sat blank with no notice -- so secure mode "looked like it worked" for several seconds. The watchdog now reacts to the iframe element's 'load' event (which fires even when the navigation ends in an error page such as that 404) and grants only a short grace (~2.5s) for the handshake; if 'load' never fires it still falls back to the 8s cap. The load listener attaches immediately if the iframe is present, otherwise on DOMContentLoaded (the relay is injected inline before the iframe element). Verified empirically in the Playground via Chrome DevTools: the iframe is built in secure mode (opaque, sandbox without allow-same-origin), tokenpluginfile returns 404, and the notice is shown with the iframe hidden -- the token does not help there because the blocker is the service worker, not the cookie (DEC-0060). Vitest 52/52 (two new tests cover the load-driven fast path).
The Playground's PHP-WASM service worker cannot serve an opaque-origin iframe, so secure mode there only ever shows the "blocked by security configuration" notice. Set iframemode=legacy in the blueprint setConfigs so the preview actually renders the package and is useful. This is a Playground-only override applied at boot; real Moodle keeps the secure default.
…e the iframe Defense in depth for secure mode: the package is served via tokenpluginfile with an executable CSP, so opening the token URL top-level (e.g. in a new tab) would run author JS as Moodle's origin. Add a `sandbox allow-scripts allow-popups allow-forms` CSP directive (secure mode + HTML only, via content_headers) so the document keeps an opaque origin however it is loaded. Tokens mirror the secure iframe sandbox; the SCORM postMessage bridge is unaffected because the iframe is already opaque. Unit test asserts the directive is present in the secure CSP. Raised while reviewing the sibling omeka-s-exelearning secure-iframe work; applied here for consistency across the eXeLearning embedders.
In secure mode the package runs in an opaque-origin sandbox, which leaves YouTube/Vimeo players and PDFs blank (the sandbox flag propagates to nested iframes; Chrome also blocks its PDF viewer without allow-same-origin). Promote those embeds to the trusted parent: a shim baked into the package replaces whitelisted-video / .pdf iframes with placeholders and reports their geometry via postMessage; an inline relay on the activity page validates + rebuilds the URL and overlays the real player inline over each placeholder. - js/exe_embed_shim.js: in-iframe, self-activates only in the opaque origin (dormant in legacy); dual-export for Vitest. - js/exe_embed_relay.js: parent-side validate (host whitelist + canonical URL rebuild + same-origin package-file invariant for PDFs) and inline overlay. - package_manager + scorm_injector: bake the shim + whitelist into every page head, alongside (and independent of) the SCORM bridge. - player_iframe::embed_whitelist(); relay inlined in view.php (secure only). PDFs: local package PDFs always render; any https .pdf renders; same-origin PDFs must belong to this package (served as application/pdf, never executable). Tests: Vitest validator/promote (exe_embed.test.js) + SCORM coexistence guard (embed_scorm_coexistence.test.js); scorm_injector_test asserts the shim is baked without dropping the SCORM bridge. Documented in DEC-0061. Fixtures under research/fixtures/elpx/.
mod_exelearning serves package assets with relative URLs (unlike the wp/omeka proxies, which rewrite to absolute), so a locally-packaged PDF was reported to the parent relay as a relative src. The relay resolves URLs against the host page, not the content, so it rejected the relative path and the local PDF did not render. The shim runs inside the content, so it now resolves each src against the content location and reports the ABSOLUTE URL. Verified live (secure mode): the embeds demo renders 4 players inline (YouTube, Vimeo, remote PDF, local package PDF). Adds a Vitest regression test (a relative src is reported absolute). Updates DEC-0061 with the live results, including the SCORM scoring validation (track.php saved a full attempt; gradebook + attempts report reflect it) confirming the embed shim does not break scoring.
- Playwright/Firefox e2e (playwright-embed.config.cjs + tests/e2e/): loads the real shim + relay against an opaque-origin sandboxed harness and asserts a whitelisted YouTube embed and a RELATIVE local PDF are promoted to inline parent players while a non-whitelisted iframe is not. Proves the promote-to-parent mechanism works in Firefox, not just Chromium. Run with `npm run test:e2e:embed`. - Vitest: add coverage for relay makePlayer() (video vs PDF attributes) and shim collect() (geometry report). The browser-bootstrap paths (init/report/observer) are covered by the e2e. - README: document external embeds in Secure mode (whitelist + PDF policy + how to run the tests).
…tch gate
- CodeQL (high): the e2e spec asserted a non-whitelisted host was absent with
src.includes('example.com') ("incomplete URL substring sanitization"). Rewrite the
assertions to parse exact hostnames (new URL().hostname) and an anchored regex for the
canonical YouTube URL, so no URL is checked by substring.
- Codecov patch: cover the relay's createRelay()/onMessage -> overlay player path and
the instance validate() in Vitest, and mark the browser-only bootstrap of both the
shim (init/report/observer) and the relay (init/pingAll/scheduleReflow) as v8-ignore
(they require a framed, opaque-origin window and are exercised by the Firefox e2e, not
happy-dom). exe_embed_relay.js 95% / exe_embed_shim.js 84% line coverage; 77 unit tests.
…dow first The vendored pipwerks API.get() started its lookup at win.parent and skipped the current window. In secure (opaque-origin) mode the SCORM API is provided locally as window.API by the in-iframe bridge shim (DEC-0059) and the Moodle parent is a cross-origin/opaque frame, so reaching into parent.API threw SecurityError, init() never activated the connection, and every LMSSetValue/LMSCommit became a silent no-op -- no attempt rows were ever written. Legacy (same-origin) mode masked it because the parent there hosts the API. Restore the standard pipwerks order: try find(window) first, then fall back to the parent and opener, with every cross-origin hop wrapped so an opaque ancestor can never abort the lookup. Legacy keeps working: with no local API, find(window) walks up to the same-origin parent exactly as before. Add tests/js/scorm_api_wrapper.test.js (loads the vendored wrapper with a controllable window) covering current-window-first, opaque-parent safety, the null fallback, the legacy walk-up, and the full init()/set() save path. Document the root cause and the DEC-0059 verification gap in DEC-0062.
…eometry
Extend the external-embed allowlist with Dailymotion (www/geo.dailymotion.com,
/embed/video/{id}) and EducaMadrid/Mediateca de Madrid (mediateca.educa.madrid.org,
/video/{id}/fs), each with a per-provider canonical-URL validator in the relay so
only a reconstructed, known-good embed URL is ever rendered. Clamp the relayed
player overlay to the placeholder's rect (defence in depth against geometry-driven
clickjacking; the overlay already clips with overflow:hidden). Mark the shim/relay
as the canonical source for the wp/omeka mirrors. Refresh the demo fixture and the
Vitest + Firefox e2e coverage with the new hosts and their reject cases.
The in-iframe shim restarts its embed-id counter on every page, so after the content navigates (e.g. eXe multi-page next/prev), the new page's first embed reuses id exe-embed-1. The relay reused the existing player for that id and only repositioned it, never updating its src -- so the previous page's video (e.g. YouTube) lingered on the next page, stretched to the new box. Tag each player with the URL it renders (data-exe-embed-src) and, in sync(), replace the player when a reused id now maps to a different URL instead of repositioning the stale one. Add a regression test covering a reused id that navigates from YouTube to Vimeo.
…rence) Mirror of the eXeLearning core canonical contract (exelearning/doc/development/preview-serving-contract.md): serve the editor preview of untrusted author content over an authless capability URL in an opaque origin, via this host's own cookieless serving primitive, so the preview gets real per-page URLs (working navigation + open-in-new-tab) instead of the srcdoc fallback. The sandbox-first CSP is emitted verbatim from core's previewCspHeader() on every scriptable document type (text/html, image/svg+xml, application/xml, application/xhtml+xml). Reference endpoint + docs; the session store, management API and tests are follow-up per repo.
Fix phpcs findings on the preview.php reference endpoint: decorative comment separator -> plain sentence, collapse 3-space inline-comment indentation to one, rephrase TODO markers (no MDL-xxxx tracker) to plain follow-up notes, and add the required trailing newline. moodle-plugin-ci phpcs now clean.
…ract v2)
Implement the eXeLearning preview serving contract v2 server side on Moodle's
own primitives, mirroring the eXe core reference (preview-session-manager.ts /
preview-serving.ts / preview-fixed-resources.ts):
- session_store: file-backed sessions under the Moodle temp dir, three layers
(generated documents, session assets, fixed refs). Atomic revisions staged
in revisions/{n} and published by an atomic `current` pointer swap so a GET
never mixes revision N and N+1; per-session lock serialises the compare-and-
swap. Enforces the contract exactly: revision 409, path-normalisation 400,
assetKey regex, asset immutability, missing-assets 422, unknown-fixed 422,
two-stage byte/file/asset budgets 413, 30-min idle TTL, per-user + global LRU
eviction.
- serving: the CSP (byte-identical to core previewCspHeader() and always
opaque — never reuses player_iframe's sandbox tokens, which can add
allow-same-origin under the legacy hatch), tiered Cache-Control, ETag/Range,
scriptable-type detection (incl. text/xml), and the management wire helpers.
- preview_session: three-layer get_file() resolution.
- fixed_resources: manifest resolver over the installed static editor, exact-id
lookup with realpath containment, graceful degradation when absent.
- preview.php: complete the authless capability-URL serving adapter (NO_MOODLE_COOKIES, UUID gate, get_file_argument slash-args), delegating the three-layer resolution and tiered/sandbox headers to the serving layer. Drops the stubbed store lookup that fatally referenced a nonexistent class. - editor/preview_session.php: a single sesskey + require_login + capability AJAX dispatcher (the editor/save.php idiom), owner-scoped to $USER, covering create / assets / revisions / delete with index-aligned multipart uploads.
Register preview_session_cleanup (every 15 min) to sweep idle-expired sessions; the serving and management paths also check the TTL opportunistically on access.
- serving_test: protocol helpers incl. the CSP byte-identity drift check. - session_store_test: revision ordering/atomicity, copy-forward + delete, budgets, traversal, asset immutability, TTL, per-user eviction, ownership. - management_test: management wire validation and 400/413/409/422 mapping. - fixed_resources_test: manifest resolution, containment, graceful degradation. - conformance_test: replays the shared vectors (vendored verbatim from core test/fixtures/preview-contract) — the Moodle port of the reference consumer.
Replace the v1 (full-manifest + re-hash) mirror with the v2 three-layer model: management API, atomic revisions, tiered headers, the fixed-resource manifest, cleanup task, and the conformance harness. Marks eXe core as canonical.
…hanges Mirror core embedder fix 054abfd0 (changes flow from core outward) into the plugin's exe_embed_shim.js / exe_embed_relay.js: - shim: report(force) with a JSON dirty-check — force on run()/load/parent 'request', observer-driven reports skip identical geometry so an attribute- noisy page cannot spam the parent. Observe class/style/hidden/open attribute flips (nav toggles reflow placeholders with no childList/scroll/resize), and re-measure on transitionend/animationend + a ResizeObserver on the doc/body. - relay: remember each overlay's lastRect and add checkDrift(), run on a 300ms interval, to re-pin overlays when the host moves the content iframe (sidebar toggles, panel slide-ins) with no scroll/resize event; expose it on the relay. Ports the core checkDrift unit test. `npx vitest run`: 7 files, 136 tests green.
…rrored exelearning_preview_uploaded_files() mapped any per-file PHP upload error (UPLOAD_ERR_INI_SIZE / _FORM_SIZE / _PARTIAL ...) to an empty string, so a generated document exceeding php.ini upload_max_filesize was published as a 0-byte file and the revision succeeded — the preview silently showed a blank page with no error. A data-integrity failure that masked a real limit. The endpoint now collects each files[] part with its upload error code, name and (only when readable) bytes; serving::collect_upload() rejects the WHOLE batch BEFORE apply_revision / store_assets when any part failed — 413 for a size error, 400 otherwise, naming the offending index/filename — and never substitutes ''. No partial revision is published. handle_assets_request / handle_revision_request wrap the guard around the existing handlers; index-alignment (writes[i] ↔ files[i]) is preserved. Tests (management_test, +6): collect_upload status mapping, and an oversized document upload asserts 413 with the store left at revision 0 and no 0-byte document written.
…invalid-UTF-8 paths Two LOW correctness fixes from review: - preview.php now defines NO_DEBUG_DISPLAY before requiring config. On a site with $CFG->debugdisplay on (dev/staging, where the preview is exercised most), a notice/warning during the serving request would otherwise be prepended to the byte-exact preview/asset body and could defeat the headers/CSP contract. This is the standard pattern for raw-body file-serving endpoints. - serving::normalize_content_path now rejects a percent-decoded path that is not valid UTF-8 (mb_check_encoding), matching core normalizeContentPath's JS decodeURIComponent, which throws (=> null => 404) on overlong forms (%C0%AF), lone continuation bytes (%80) and lone surrogates (%ED%A0%80). Previously only '%' not followed by two hex digits was rejected, so an overlong-encoded separator slipped through as raw bytes. All existing traversal rejections (.., backslash, NUL, absolute) are kept. Tests (serving_test, +2): invalid-UTF-8 rejection with valid ASCII/multibyte still accepted, and a guard asserting preview.php defines NO_DEBUG_DISPLAY before config.
…timer/listeners) Mirror the core embedder change (changes flow from core outward): the relay's checkDrift interval and window listeners previously ran for the whole page lifetime with no way to stop them, and a second init() would stack a duplicate interval + listeners. - Track the interval id (driftTimer) and a `started` flag in createRelay(). - init() is now idempotent (returns early if already started) and stores the interval id instead of a bare setInterval. - New dispose() tears down the overlays (via a clear() helper mirroring core) and, browser-side, clears the drift interval and removes the message/resize/ scroll/load listeners, then resets `started` so a reused relay can init again. Exposed on the relay object; safe to call before init() or twice. This mirror lacked core's clear()/reflow(); clear() is added here as the private teardown helper dispose() needs (reflow() is unrelated and not pulled in). Ports the core dispose unit test. `npx vitest run`: 7 files, 137 tests green.
…Checker
moodle-plugin-ci's phpdoc check (local_moodlecheck, --max-warnings 0) reports
"incomplete parameters list" on @param tags typed with PHPStan-style generics
/ array shapes (array<...> / array{...}): its parameter parser cannot match
those to the signature. phpcs accepts them, so composer lint stayed green and
this only surfaced in the stricter PHPDoc Checker step.
Convert every affected @param to a plain `array` type with a short description
of the shape (serve, collect_upload, handle_assets_request,
handle_revision_request, set_limits_for_testing, store_assets, apply_revision,
publish_revision, lru_of, exelearning_preview_emit, exelearning_preview_send,
and the two test helpers). @return shapes and `type[]` params are left as-is —
the checker accepts those.
Verified clean with local_moodlecheck cli/moodlecheck.php on every touched file;
composer lint and the preview PHPUnit suite stay green.
Grow the serving protocol class with the pieces the normalized HTTP preview
contract v2.1 needs, without yet touching the entry-point scripts:
- route_management(): map an HTTP method + PATH_INFO tail onto the four contract
operations (create / assets / revisions / delete), replacing the legacy
action= dispatch. Unknown path -> 404, known path + wrong method -> 405 (Allow).
- parse_capability_path(): split "/{previewId}/{relpath}" and flag the bare-root
form so the endpoint can redirect it.
- redirect_to_index(): the 302 response for the bare capability root.
- parse_range(): a malformed / multi-range / non-"bytes" header is now ignored
(served as a normal 200 full body) instead of answering 416; 416 is reserved
for a syntactically valid but unsatisfiable single range.
…ng state
The asset store discarded file_put_contents()'s return: on a failed or short
write the key was still indexed, its bytes counted, and it was reported stored —
so a later revision passed the asset-existence check and then 404'd at serve
time with corrupt byte counters. Index a key only once its bytes are durably
written; on failure report rejected {reason: "write-failed"} and touch nothing.
Do the same for revision publication: a failed document write or copy while
staging now aborts publish_revision BEFORE the atomic pointer swap (returns a
500-mapped error and discards the staged revision), so the active revision is
never left mixed or truncated. Regression tests force each failure root-safely
(a directory/file collision, a removed copy source).
…root
Wire the entry points to the new serving helpers:
- editor/preview_session.php now routes on REQUEST_METHOD + PATH_INFO via
serving::route_management(), dropping the required action= and previewid= query
params. cmid and sesskey stay query params (the client sends them as
managementQuery). Auth, early write_close(), owner scoping and multipart
handling are unchanged; an unknown path is 404 and a bad method 405.
- preview.php redirects the bare capability root (/{previewId} and /{previewId}/)
to {previewId}/index.html before the session lookup, so the opaque iframe's
base URL is the session directory and no document bytes are served bare.
Tests cover the router mapping/rejections, the capability-path split, the 302
helper, and the Range fallback (malformed/multi-range -> full 200, valid
unsatisfiable -> 416).
editor/index.php adds the previewHttp block to window.__EXE_EMBEDDING_CONFIG__
(protocolVersion 2, managementBaseUrl -> editor/preview_session.php,
servingBaseUrl -> preview.php, managementQuery {cmid, sesskey}) so a
preview-capable editor build selects the opaque HTTP transport against this
plugin's own routes. Auth is the login cookie plus the sesskey query value, so
no managementHeaders are needed.
The block is omitted under the php-wasm Playground (MOODLE_PLAYGROUND): a service
worker cannot serve a genuinely opaque iframe, so the editor must fail closed
there rather than silently downgrade the isolation boundary. Older editor builds
ignore the block, so nothing changes until such a build is installed.
…d policy - Rewrite the management-API section around the method+path dispatcher (no more action=), and document the failed-write rejection and the publish-before-swap atomicity guarantee. - Add the serving deltas: the bare-root 302 redirect and the Range fallback. - Replace the dormant "client wiring (follow-up)" note with the activated previewHttp block, the two-URL trust model, the editor-build dependency, and the Playground fail-closed policy. - Fix the broken core-relative links in the conformance-vectors README (point at this repo's docs and the upstream GitHub URLs).
Assert at the source level (editor/index.php is an entry point outside coverage scope) that the bootstrap injects the previewHttp activation block pointing at preview_session.php + preview.php, carries managementQuery, and gates the block on MOODLE_PLAYGROUND so a preview-capable editor build fails closed there.
A silently-failed revision.json write followed by the current-pointer swap would publish a revision with no document map — every served path 404s, the exact partial-revision state the contract forbids. Check the manifest write like the document writes: on failure discard the staged revision and return a 500-mapped error BEFORE the swap, so the active revision stays intact. A regression test forces the failure root-safely with a zero-document revision (revision.json is then the only write) whose staged path is occupied by a plain file.
Converge the Range classification with the frozen contract §4: 416 is reserved for a syntactically VALID single range that cannot be satisfied (first-byte-pos >= length, or a zero suffix bytes=-0). An inverted spec whose last-byte-pos is below its first-byte-pos (bytes=5-2) is an invalid byte-range-spec per RFC 9110, so it is ignored and served as a normal 200 full body — like a non-bytes unit, a multi-range set, or unparseable garbage. Updates parse_range, its test, and docs.
store_assets() called evict_others_for_budget() inside the per-entry loop, and that unconditionally enumerate()d the whole store (scandir + read_json of every session's meta) once PER asset — a 5000-file batch meant thousands of full-store scans in one request (O(entries × sessions)). Take the global-ceiling snapshot at most ONCE per batch and evict from (and mutate) it in place across entries, never re-enumerating; per-entry rejection semantics are unchanged. evict_others_for_ budget() (apply_revision's single call) now delegates to the same two helpers, keeping one source of truth. A test proves enumerate() runs once for a five-asset batch (instrumentation seam), plus one covering the LRU-eviction path.
…t 416) parse_range() checked satisfiability (first-byte-pos >= length -> 416) BEFORE the inverted-spec guard (last < first -> ignore), so bytes=15-2 on a <=15-byte asset returned 416. The frozen contract (§4) requires structural invalidity to win: an inverted byte-range-spec is invalid per RFC 9110 and is ignored (served as a full 200), even when the first-byte-pos is also beyond the body. Reordered the checks and added bytes=15-2-on-a-short-body to test_parse_range.
The bootstrap neutralizes the static editor's preview-sw.js registration to keep
its 404 (on hosts that don't serve the SW script) off the console. It returned a
bare { scope: "" }, but the editor's preview provider treats the registration as
an EventTarget — registration.addEventListener("updatefound", …) — and reads
installing/waiting/active, so the bare object threw "addEventListener is not a
function" and aborted the preview/export iframe. Return a faithful shape instead:
a non-empty scope, null workers (so the provider's activation wait resolves at
once and it claims no clients) and no-op event/lifecycle methods, letting the
provider complete and fall back cleanly. Verified against the bundled editor's
ServiceWorkerPreviewProvider usage in dist/static/app/app.bundle.js.
…ot Location
Re-vendor tests/fixtures/preview-contract/vectors.json verbatim from core
(5675623a): adds the bare-root-302 step and the Range cases garbage / multi /
non-bytes / bytes=5-2 / bytes=15-2 -> 200-ignore and bytes=-0 -> 416. serve() and
parse_range already produce these; the conformance harness gains plumbing for the
two new step shapes (bare-root redirect before session lookup, and {previewId}
substitution in expected header values) without forking the JSON.
The bare-root redirect now emits a RELATIVE Location resolved against the request
URL — "{previewId}/index.html" without a trailing slash, "index.html" with one —
via serving::bare_root_location(), so it is correct under any $CFG->wwwroot
subdirectory and byte-matches the canonical vector (was an absolute wwwroot URL).
Also reword the intro away from the removed `srcdoc` fallback: the embed uses the
HTTP preview or fails closed; static-service-worker is standalone-only.
…comment
test_editor_bootstrap_sw_stub_is_faithful asserted the bare `{ scope: "" }` stub
was absent from editor/index.php, but the code comment that documents WHY the
faithful shape is needed names that literal — so the negative substring match hit
the comment and failed on every matrix cell. Retarget the assertion at the actual
bare RETURN pattern (`Promise.resolve({ scope: "" })`), which the comment does not
contain and a regression would; the positive checks (fakeSwRegistration, the
no-op addEventListener) are unchanged. Test-only; the stub itself is correct.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This branch delivers two related security features:
Production iframes omit
allow-same-origin. Author HTML and JavaScript cannot reach the Moodle page, session, storage, or APIs.Published-content viewer
EXELEARNING_UNSAFE_LEGACY_IFRAMEescape hatch applies only to the published viewer in php-wasm Playground environments. It is not an administration setting and is forbidden for production editor preview.HTTP editor preview: protocol v2
The plugin exposes:
Management is protected by:
require_login;require_sesskey;require_capability('moodle/course:manageactivities')in the activity context;cmidbinding.Serving is cookieless and authless. The unguessable preview UUID and idle TTL form the capability.
Protocol v2 separates:
Session store
Sessions are stored under Moodle's private temporary directory, outside the public web root. The store implements:
A failed asset or document write is never indexed or activated.
Serving behavior
text/xml, and XHTML.302from a bare capability URL to{previewId}/index.html.206for a valid satisfiable single range.416for a valid unsatisfiable range, includingbytes=-0.200when Range is malformed, multi-range, non-bytes, or inverted.Editor configuration
Outside Moodle Playground,
editor/index.phpinjects:{ "previewHttp": { "protocolVersion": 2, "managementBaseUrl": "{wwwroot}/mod/exelearning/editor/preview_session.php", "servingBaseUrl": "{wwwroot}/mod/exelearning/preview.php", "managementQuery": { "cmid": "...", "sesskey": "..." } } }The client appends
managementQueryto every management request and never sends it to the serving capability URL.Playground policy
A php-wasm Service Worker cannot serve an opaque iframe. Moodle Playground therefore omits
previewHttpand fails closed by default.A development blueprint may deliberately enable same-origin preview only with both fields:
{ "previewTransport": "static-service-worker", "allowUnsafeEmbeddedPreview": true }The transport name alone is rejected. This configuration is development-only, visibly warned by the core editor, and must never be exposed as a Moodle setting.
Activation status
The currently bundled released editor predates
HttpPreviewProvider. The Moodle routes, store, bootstrap configuration, CSP, bridges, and conformance tests are implemented, but the released editor does not yet drive the HTTP preview.docs/testing-http-preview-core-artifact.mddocuments the final integration gate: build one static-editor artifact from the target core commit, record its SHA-256, install it in Moodle, and demonstrate create → assets → revision → opaque capability iframe → incremental update → cleanup.Production activation depends on a core release containing:
No complete browser activation is claimed until that build is tested inside Moodle.
Tests
Canonical source
eXeLearning core is the canonical source for the serving contract, CSP, scriptable MIME policy, bridge files, and conformance vectors. Moodle implements a framework-native adapter and synchronized mirrors.
Known limitations