Skip to content

Secure opaque-origin viewer, SCORM/xAPI bridge and HTTP editor preview v2#80

Draft
erseco wants to merge 81 commits into
mainfrom
feature/secure-iframe-scorm-bridge
Draft

Secure opaque-origin viewer, SCORM/xAPI bridge and HTTP editor preview v2#80
erseco wants to merge 81 commits into
mainfrom
feature/secure-iframe-scorm-bridge

Conversation

@erseco

@erseco erseco commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

This branch delivers two related security features:

  1. An opaque-origin published-content viewer with SCORM, xAPI, external-media, and interactive-video bridges.
  2. A Moodle host adapter for eXeLearning HTTP editor preview, Preview Serving Contract v2.

Production iframes omit allow-same-origin. Author HTML and JavaScript cannot reach the Moodle page, session, storage, or APIs.

Published-content viewer

  • Content is served through the Moodle file API with a response-level sandbox CSP.
  • SCORM 1.2/2004 and xAPI calls are relayed to the trusted parent through validated message channels.
  • External media uses the shared eXeLearning shim/relay and strict provider policy by default.
  • The development-only EXELEARNING_UNSAFE_LEGACY_IFRAME escape 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:

POST/DELETE {wwwroot}/mod/exelearning/editor/preview_session.php/{previewId?}/{operation?}
GET         {wwwroot}/mod/exelearning/preview.php/{previewId}/{path}

Management is protected by:

  • require_login;
  • require_sesskey;
  • require_capability('moodle/course:manageactivities') in the activity context;
  • per-session ownership;
  • cmid binding.

Serving is cookieless and authless. The unguessable preview UUID and idle TTL form the capability.

Protocol v2 separates:

  • fixed installation resources, never uploaded;
  • immutable project assets, uploaded once per session;
  • generated documents, sent only when changed and published atomically.

Session store

Sessions are stored under Moodle's private temporary directory, outside the public web root. The store implements:

  • immutable asset keys;
  • staged revision publication and final pointer swap;
  • Moodle core locking;
  • checked writes and manifest persistence;
  • quotas and LRU eviction;
  • 30-minute idle TTL;
  • scheduled and opportunistic cleanup.

A failed asset or document write is never indexed or activated.

Serving behavior

  • Sandbox-first CSP on HTML, SVG, XML, text/xml, and XHTML.
  • Hardening headers on all responses, including errors.
  • Relative 302 from a bare capability URL to {previewId}/index.html.
  • 206 for a valid satisfiable single range.
  • 416 for a valid unsatisfiable range, including bytes=-0.
  • Full 200 when Range is malformed, multi-range, non-bytes, or inverted.
  • ETag and conditional requests for session assets.

Editor configuration

Outside Moodle Playground, editor/index.php injects:

{
  "previewHttp": {
    "protocolVersion": 2,
    "managementBaseUrl": "{wwwroot}/mod/exelearning/editor/preview_session.php",
    "servingBaseUrl": "{wwwroot}/mod/exelearning/preview.php",
    "managementQuery": {
      "cmid": "...",
      "sesskey": "..."
    }
  }
}

The client appends managementQuery to 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 previewHttp and 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.md documents 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:

HttpPreviewProvider
StaticServiceWorkerPreviewProvider
bundles/preview-fixed-resources.json

No complete browser activation is claimed until that build is tested inside Moodle.

Tests

  • Full Moodle/PHP/database PHPUnit matrix.
  • Serving, management, storage, fixed-resource and conformance tests.
  • Write-failure, Range, CSP, traversal, ownership and cleanup regressions.
  • Vitest for SCORM/xAPI/media bridge code.
  • Playwright external-media coverage.
  • Moodle Playground workflow.

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

  • Browser activation remains gated on a compatible core editor release.
  • The legacy released editor still needs narrowly scoped bootstrap compatibility workarounds; these should be removed or version-gated when the minimum bundled editor includes HTTP preview v2.
  • Playground cannot provide secure editor preview without a real backend.

@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.61290% with 39 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
classes/local/ui/player_iframe.php 76.47% 12 Missing ⚠️
js/exe_embed_shim.js 83.60% 10 Missing ⚠️
js/exe_embed_relay.js 94.15% 9 Missing ⚠️
classes/local/package_manager.php 75.00% 4 Missing ⚠️
lib.php 0.00% 3 Missing ⚠️
js/scorm_bridge_relay.js 98.55% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

erseco and others added 14 commits June 13, 2026 09:26
…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.
Comment thread js/exe_embed_shim.js Fixed
- 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).
Comment thread tests/e2e/embed.spec.cjs Fixed
erseco added 3 commits June 14, 2026 06:54
…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.
Comment thread js/exe_embed_shim.js Fixed
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.
erseco added 21 commits July 6, 2026 17:28
…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.
@erseco erseco changed the title Add secure iframe mode: opaque-origin sandbox + SCORM postMessage bridge Secure opaque-origin iframe: published-content sandbox + SCORM/xAPI bridge + HTTP editor preview (serving contract v2) Jul 11, 2026
erseco added 6 commits July 11, 2026 13:44
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.
@erseco erseco changed the title Secure opaque-origin iframe: published-content sandbox + SCORM/xAPI bridge + HTTP editor preview (serving contract v2) Secure opaque-origin viewer, SCORM/xAPI bridge and HTTP editor preview v2 Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants