cyborg-hunter 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/package.json +10 -2
  9. package/src/cli/ingest.js +311 -57
  10. package/src/cli/renderers/html-index-core.js +66 -12
  11. package/src/cli/renderers/html-index.js +7 -5
  12. package/src/cli/renderers/replay-assets.js +61 -7
  13. package/src/cli/renderers/replay-client-source.js +102 -0
  14. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  15. package/src/cli/report.js +6 -0
  16. package/src/core/monitor.js +12 -13
  17. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  18. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  19. package/src/replay/capture-dom.js +470 -458
  20. package/src/replay/capture-trace.js +688 -271
  21. package/src/replay/delivery.js +82 -0
  22. package/src/replay/dom-instantiate.js +779 -0
  23. package/src/replay/index.js +88 -5
  24. package/src/replay/initial-state.js +295 -0
  25. package/src/replay/mutations.js +668 -0
  26. package/src/replay/node-registry.js +116 -0
  27. package/src/replay/persistence.js +19 -6
  28. package/src/replay/recorder.js +342 -73
  29. package/src/replay/redaction.js +165 -0
  30. package/src/replay/serializer.js +148 -43
  31. package/src/replay/snapshot.js +409 -0
  32. package/src/replay/span.js +55 -0
  33. package/src/replay/viewer-model.js +293 -102
  34. package/src/shared/constants.js +1 -1
  35. package/src/shared/inline-safe.js +80 -0
  36. package/src/shared/schema-v2-validator.js +595 -0
  37. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
@@ -0,0 +1,779 @@
1
+ // src/replay/dom-instantiate.js
2
+ // SessionRecording v2 keyframe (a DomNode tree, spec §4) → real DOM inside the
3
+ // reconstruction frame, plus the `Map<number, Node>` every `dom.*` patch
4
+ // (§5.1), every event `target`, and every `anchor.node` (§6) resolves through —
5
+ // and, since T5 Task 3, the application of those four patches through that map.
6
+ //
7
+ // Instantiation and patching live together because they are ONE READING OF §4.
8
+ // `dom.add` instantiates a subtree into the same id map, reverse index and
9
+ // canvas map a keyframe fills, and its namespace, its §12 filters and its
10
+ // tolerance for malformed nodes are the keyframe walk's, not a second set that
11
+ // happens to agree today. Splitting them would create the drift this migration
12
+ // exists to remove. (The concatenation contract below is a second, weaker
13
+ // reason: a separate module would have to import this one, and while an
14
+ // assembler that strips one export line could strip two, the reading argument
15
+ // stands on its own.)
16
+ //
17
+ // THIS IS WHERE THE HTML-STRING ERA ENDS, and that is a security result rather
18
+ // than a refactor. v1 built the reconstruction by interpolating a captured HTML
19
+ // string into the frame's `srcdoc` and applied child-list patches with
20
+ // `innerHTML`. Nothing here parses markup: every node is created by name and
21
+ // every attribute set by `setAttribute`, so `</script>` breakouts,
22
+ // attribute-escaping bugs and parser-normalisation drift (nested forms, table
23
+ // fostering, duplicate ids — the reason v1's reference resolution needed a
24
+ // tag-validated path fallback) stop being defended against and start being
25
+ // impossible. The claim the dogfood suite used to make — "injected markup
26
+ // arrives defanged" — becomes the stronger "injected markup is never parsed".
27
+ //
28
+ // Recording content is UNTRUSTED (spec §12): attacker-controlled DOM, CSS and
29
+ // URLs. CH's own capture already withholds `on*` handlers and iframe `srcdoc`,
30
+ // but a conforming file from a foreign producer is not bound by CH's capture
31
+ // rules, so the filters below are a PLAYER duty and are applied to every file.
32
+ //
33
+ // PURE and BROWSER-SAFE: no Node APIs, no imports, and — see the export block
34
+ // at the bottom — exactly one line of ESM syntax. The report's viewer client
35
+ // (`src/cli/renderers/replay-viewer.client.js`) is a plain IIFE inlined
36
+ // verbatim into the report and cannot `import`, so the build CONCATENATES this
37
+ // module ahead of it with that one line stripped (T5 Task 2's recorded
38
+ // decision; `tests/replay/dom-instantiate.test.js` machine-checks that the
39
+ // source stays concatenable and strict-safe). The alternative — the client
40
+ // carrying its own copy — would put two readings of §4 in the repo, which is
41
+ // the failure this whole migration exists to remove. Style follows the client
42
+ // and `snapshot.js` (`var`, `function`) for the same reason.
43
+
44
+ // Spec §4's exclusion placeholder is `{id, kind, tag}` with the `attrs` and
45
+ // `children` keys ABSENT, so `attrs || {}` and `children || []` below ARE the
46
+ // placeholder handling: a bare, empty element of that tag, holding its id and
47
+ // its sibling position. It is written as a fallback rather than a branch
48
+ // because that is exactly what it is, and because the fork crashed on the
49
+ // shape when it read the keys as required.
50
+
51
+ // Executable content never enters the reconstruction as itself. A foreign
52
+ // producer may serialise `script`/`noscript` (CH's own capture skips them
53
+ // outright); they are instantiated as inert `<template>`s, which the UA never
54
+ // renders and never runs, so ids and sibling positions stay coherent instead
55
+ // of the tree developing a hole a later patch would miss. Their children are
56
+ // instantiated too — the source text keeps its id, so a later `dom.text` still
57
+ // lands. WHERE those children sit is realm-dependent and deliberately not
58
+ // relied on: happy-dom routes `appendChild` on a template into `content`,
59
+ // browsers keep it in `childNodes`, and neither is rendered or executed.
60
+ var INERT_TAGS = { script: true, noscript: true };
61
+
62
+ // Attributes whose value is a URL the browser will follow. Everything else is
63
+ // page content: a `title` that reads like a `javascript:` URL is text.
64
+ var URL_ATTRS = {
65
+ href: true, src: true, action: true, formaction: true, 'xlink:href': true,
66
+ };
67
+
68
+ // The XML `Name` production, ASCII subset — the same predicate capture uses in
69
+ // `isSerializableAttr` (`snapshot.js`), duplicated rather than imported because
70
+ // this file must survive concatenation as a plain script (header). It gates
71
+ // both attribute names and element tags.
72
+ //
73
+ // WHY IT IS HERE, corrected in fix round 1 after measurement. The first version
74
+ // of this comment said a real browser's `setAttribute` throws
75
+ // `InvalidCharacterError` on a name outside this production. It does not:
76
+ // chromium, firefox and webkit all accept `setAttribute('<', 'v')`, and all
77
+ // three mount `jspsych-full`'s malformed free-sort node unfiltered. happy-dom
78
+ // 20.9.0 is the realm that throws on it. Three reasons survive, and they are
79
+ // the reasons the filter stays:
80
+ // 1. REALM DETERMINISM. The reconstruction runs in happy-dom under `npm
81
+ // test`, in three browser engines under the Task-8 battery, and in the
82
+ // analyst's browser in production. A viewer whose tree depends on which
83
+ // one is executing cannot be a conformance player. Filtering by a fixed
84
+ // predicate gives one answer everywhere; deferring to the host gives
85
+ // three.
86
+ // 2. PARITY WITH CAPTURE. CH's own recorder refuses the same names, so a CH
87
+ // file can never carry one. Sharing the predicate is what makes the
88
+ // corpus pin (`['<']`, exactly one attribute) mean something.
89
+ // 3. SOME NAMES REALLY DO ABORT. Every engine measured, happy-dom included,
90
+ // throws on a whitespace-bearing name such as `a b`. A player with no
91
+ // name filter loses the whole mount on a hostile file. Same for tags:
92
+ // `createElement('')`, `('<div')`, `('a b')` and `('1abc')` all throw in
93
+ // browsers, and happy-dom throws on `''` alone — the divergence reason 1
94
+ // exists for.
95
+ // The cost is real and is recorded rather than hidden: the engines accept
96
+ // `@click`, `[ngModel]`, `(click)`, `*ngIf`, `1x` and `café`, and this drops
97
+ // them all. CH files never carry them; foreign producers might.
98
+ var NAME_TOKEN_RE = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
99
+
100
+ // Tags only, on top of the Name production: the XML namespace prefixes are
101
+ // RESERVED, and `createElementNS` enforces that where `createElement` does not.
102
+ // Measured tri-engine (playwright-core 1.62.1): `createElementNS(<any ns>,
103
+ // 'xml:foo' | 'xmlns:foo' | 'xmlns')` throws `NamespaceError` on chromium,
104
+ // firefox and webkit, while happy-dom 20.9.0 accepts all three — so without
105
+ // this line a foreign `svg` subtree carrying one aborts the whole mount in the
106
+ // analyst's browser and passes every node test. `a:b` and a bare `xml` are
107
+ // legal everywhere and stay legal. Case is not part of the rule but is not a
108
+ // gap either: the walk lowercases a tag before creating it, so `XML:Foo`
109
+ // reaches `createElementNS` as `xml:foo` and is caught here.
110
+ //
111
+ // It gates TAGS ONLY, deliberately. `setAttribute` does no namespace
112
+ // validation, so `xmlns:xlink="…"` — which real SVG markup carries — sets
113
+ // fine and is kept.
114
+ var RESERVED_TAG_PREFIX_RE = /^(?:xml|xmlns):|^xmlns$/;
115
+
116
+ var ELEMENT_NODE = 1;
117
+ var TEXT_NODE = 3;
118
+ var COMMENT_NODE = 8;
119
+
120
+ // Foreign content (spec-r3 gap, recorded). Spec §4's DomNode carries no
121
+ // namespace, so a player has to infer one, and `createElement` alone cannot
122
+ // reconstruct SVG at all: an `svg > circle` built that way lands in the XHTML
123
+ // namespace and lays out at 0×0, where the same markup PARSED lands in the SVG
124
+ // namespace and lays out at its viewport. v1 built the reconstruction from an
125
+ // HTML string and got the parser's foreign-content rules for free; this module
126
+ // has to state them. The rule below is the parser's, reduced to what a keyframe
127
+ // can express: an `svg` or `math` element opens its namespace, descendants
128
+ // inherit it, and the SVG HTML-integration points hand their children back to
129
+ // XHTML.
130
+ //
131
+ // Known incompleteness, both of them format-level rather than player-level:
132
+ // capture lowercases every tag (`snapshot.js`), so camelCase SVG children
133
+ // (`linearGradient`, `clipPath`) and `foreignObject` itself arrive folded and
134
+ // cannot be re-cased from the file; and MathML's own integration points
135
+ // (`annotation-xml`, `mi`/`mo`/`mn`/`ms`/`mtext`) are not modelled. Both belong
136
+ // in the spec-r3 conversation, not in a workaround here.
137
+ var XHTML_NS = 'http://www.w3.org/1999/xhtml';
138
+ var SVG_NS = 'http://www.w3.org/2000/svg';
139
+ var MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
140
+ var FOREIGN_ROOTS = { svg: SVG_NS, math: MATHML_NS };
141
+ var SVG_HTML_INTEGRATION = { foreignobject: true, desc: true, title: true };
142
+
143
+ // Stamped on elements the format cannot carry the content of (spec §12/§13),
144
+ // so the shell stylesheet can outline the region and the header chip can count
145
+ // it. Only iframes need it today: their content is a separate browsing context
146
+ // no recorder observes.
147
+ var PLACEHOLDER_ATTR = 'data-ch-placeholder';
148
+
149
+ // Capture's own flag for a shadow host (`snapshot.js`, spec §13): the light-DOM
150
+ // children are ordinary capturable nodes, the shadow ROOT's content is not.
151
+ var SHADOW_ATTR = 'data-ch-shadow';
152
+
153
+ // The canvas-presentation key (design §3.1, route 2). A script-less sandboxed
154
+ // frame holds canvas pixels it never paints, so `canvas.snapshot` composites in
155
+ // a parent-owned offscreen canvas and is PRESENTED as a background image. The
156
+ // viewer stamps this name on the canvas it composites into and matches it from
157
+ // a rule in the shell head; `CANVAS_RULE_ATTR` marks the rule element itself.
158
+ // Presenting through the head instead of the element's inline `style` is what
159
+ // makes the presentation survive a recorded `dom.attr` naming `style` — per
160
+ // CSSOM that write replaces the whole inline declaration block.
161
+ var CANVAS_ATTR = 'data-ch-canvas';
162
+ var CANVAS_RULE_ATTR = 'data-ch-canvas-rule';
163
+
164
+ // VIEWER-OWNED ATTRIBUTES. These names are not page content: they are what
165
+ // the reconstruction says about ITSELF, and the viewer's chips read them back
166
+ // out of the live DOM — the iframe/placeholder chip through
167
+ // `iframe, [data-ch-placeholder="iframe"]`, the shadow chip through
168
+ // `[data-ch-shadow]`. So a recording that could write them would be choosing
169
+ // what the viewer reports about the reconstruction, and the failure runs in
170
+ // BOTH directions:
171
+ //
172
+ // FORGE — stamping `data-ch-placeholder` on any element fakes a "content was
173
+ // here" outline and chip over a region that was fully captured. Both names
174
+ // pass the Name-production filter, so nothing else stops it.
175
+ // STRIP — `dom.attr` with `value: null` reaches `removeAttribute`, which
176
+ // validates nothing in any realm (that is why the removal path carries no
177
+ // name filter), so a recording can delete a flag the viewer itself applied.
178
+ // This is the worse half: hiding the shadow chip turns spec §13's "absence
179
+ // of evidence is not evidence of absence" into silence, and an analyst
180
+ // reads an empty region as an empty region.
181
+ //
182
+ // ONE predicate therefore gates BOTH verbs on BOTH paths — the keyframe walk
183
+ // and `dom.attr` — and the viewer re-stamps the flags itself from the DomNode
184
+ // data (below). A recording can still CLAIM a shadow host in a keyframe, which
185
+ // is exactly what capture's own flag is; what it cannot do is move, invent or
186
+ // erase the claim afterwards.
187
+ //
188
+ // Scoped to the names the viewer stamps, NOT to the whole `data-ch-*`
189
+ // namespace: `data-ch-redact` is the researcher's own marker on their own page
190
+ // and is serialised into keyframes, and the honeypot's `data-ch-role` /
191
+ // `data-ch-decoy` are page-authored too. Dropping those would delete
192
+ // attributes the page really had and silently break any CSS keyed on them.
193
+ //
194
+ // The canvas pair joined the set in T5.5 for a reason with more teeth than the
195
+ // chips': `data-ch-canvas` is the SELECTOR the presented composite is keyed on,
196
+ // so a recording that could strip it would blank a canvas the analyst is
197
+ // looking at, and one that could forge it would paint another element with a
198
+ // canvas's pixels.
199
+ var VIEWER_OWNED_ATTRS = {};
200
+ VIEWER_OWNED_ATTRS[PLACEHOLDER_ATTR] = true;
201
+ VIEWER_OWNED_ATTRS[SHADOW_ATTR] = true;
202
+ VIEWER_OWNED_ATTRS[CANVAS_ATTR] = true;
203
+ VIEWER_OWNED_ATTRS[CANVAS_RULE_ATTR] = true;
204
+
205
+ function isViewerOwnedAttr(name) {
206
+ return VIEWER_OWNED_ATTRS[String(name).toLowerCase()] === true;
207
+ }
208
+
209
+ function isNameToken(name) {
210
+ return NAME_TOKEN_RE.test(name);
211
+ }
212
+
213
+ // The tag guard, applied at the instantiation boundary for keyframes and for
214
+ // every `dom.add` subtree alike.
215
+ function isUsableTag(tag) {
216
+ return isNameToken(tag) && !RESERVED_TAG_PREFIX_RE.test(tag);
217
+ }
218
+
219
+ // The namespace an element is created in, given its parent's.
220
+ function namespaceFor(tag, parentNs) {
221
+ if (FOREIGN_ROOTS[tag]) return FOREIGN_ROOTS[tag];
222
+ return parentNs || XHTML_NS;
223
+ }
224
+
225
+ // The namespace this element's CHILDREN are created in. Inside SVG, the HTML
226
+ // integration points hold ordinary HTML.
227
+ function childNamespaceOf(tag, ns) {
228
+ if (ns === SVG_NS && SVG_HTML_INTEGRATION[tag] === true) return XHTML_NS;
229
+ return ns;
230
+ }
231
+
232
+ /**
233
+ * Would this attribute value navigate to script?
234
+ *
235
+ * ASCII whitespace and C0 controls are stripped from ANYWHERE in the value
236
+ * before the scheme is tested, because the HTML and URL parsers remove tabs
237
+ * and newlines from URLs and trim leading controls — so `java\tscript:alert(1)`
238
+ * navigates, and a scheme test that trims only the ends is bypassed by one
239
+ * tab. Stripping cannot create a false positive: it can only remove characters
240
+ * from a scheme that was already there.
241
+ */
242
+ function isSafeUrl(value) {
243
+ var probe = String(value).replace(/[\x00-\x20]/g, '').toLowerCase();
244
+ return probe.indexOf('javascript:') !== 0 && probe.indexOf('vbscript:') !== 0;
245
+ }
246
+
247
+ /**
248
+ * Set one attribute through the §12 filters. Refused attributes are DROPPED,
249
+ * not neutralised: a `javascript:` href rewritten to `#` would claim the page
250
+ * had a link it did not have.
251
+ *
252
+ * TWO refusal classes, counted differently (fix round 1, review I-4 — the first
253
+ * version of this counted neither, on an argument that only holds for one of
254
+ * them):
255
+ *
256
+ * - **security** — an `on*` handler, or a `javascript:`/`vbscript:` value in
257
+ * a URL attribute. Dropping these is the point, and the reconstruction is
258
+ * visually right without them, so nothing is counted. Counting would light
259
+ * the analyst's "recorded change(s) could not be reapplied" chip every time
260
+ * a filter worked.
261
+ * - **name token** — a name outside the XML `Name` production. Those are page
262
+ * state that is LOST: chromium, firefox and webkit all accept `@click`,
263
+ * `[ngModel]`, `(click)`, `*ngIf`, `1x`, `café` and `<` (Task 2's
264
+ * tri-engine table), and this module drops them anyway, for the
265
+ * realm-determinism reasons in the header. That loss gets a surface —
266
+ * `skipped`, the counter for "the file said something no DOM here could
267
+ * hold" — rather than only a comment.
268
+ */
269
+ function setFilteredAttr(el, name, value, ctx) {
270
+ if (!isNameToken(name)) {
271
+ if (ctx) ctx.skipped++;
272
+ return;
273
+ }
274
+ if (/^on/i.test(name)) return;
275
+ // Viewer-owned (see VIEWER_OWNED_ATTRS): refused on the SET path so a
276
+ // recording cannot forge a placeholder outline or a shadow chip. Uncounted,
277
+ // like the other integrity refusals — the reconstruction is visually right
278
+ // without it, and counting would light the analyst's "could not be
279
+ // reapplied" chip every time a filter worked. The viewer re-stamps the flags
280
+ // it recognises itself, in `instantiateElement`.
281
+ if (isViewerOwnedAttr(name)) return;
282
+ var text = value == null ? '' : String(value);
283
+ if (URL_ATTRS[name.toLowerCase()] === true && !isSafeUrl(text)) return;
284
+ el.setAttribute(name, text);
285
+ }
286
+
287
+ function applyAttrs(el, attrs, skip, ctx) {
288
+ for (var name in attrs) {
289
+ if (!Object.prototype.hasOwnProperty.call(attrs, name)) continue;
290
+ if (skip && skip[name.toLowerCase()] === true) continue;
291
+ setFilteredAttr(el, name, attrs[name], ctx);
292
+ }
293
+ }
294
+
295
+ // Frames are recorded as the element only (spec §13) and must stay that way in
296
+ // the reconstruction: no `src` to fetch, no `srcdoc` to parse. §12's network
297
+ // policy is then satisfied structurally — there is nothing to request — with
298
+ // the viewer's `frame-src 'none'` CSP as the belt to that brace.
299
+ var IFRAME_SKIP = { src: true, srcdoc: true };
300
+
301
+ // Design §7 renders media as a state badge and a lane marker, with no playback,
302
+ // and honours `media_src` only so the element has its shape. `autoplay` is the
303
+ // one recorded attribute that would start playback with nobody asking, and the
304
+ // shell CSP allows `media-src *`, so it is dropped where it lands.
305
+ var MEDIA_TAGS = { video: true, audio: true };
306
+ var MEDIA_SKIP = { autoplay: true };
307
+
308
+ // Which recorded attributes this tag never receives.
309
+ function skipSetFor(tag) {
310
+ if (tag === 'iframe') return IFRAME_SKIP;
311
+ if (MEDIA_TAGS[tag] === true) return MEDIA_SKIP;
312
+ return null;
313
+ }
314
+
315
+ function instantiateNode(domNode, ctx, parentNs) {
316
+ // Tolerant posture (design §4): an unusable node is skipped and counted, not
317
+ // thrown on. `instantiateTree` has no try/catch by design — one guard at the
318
+ // boundary, one predicate, the same answer in every realm — so this is the
319
+ // line that stops a hand-edited or foreign file from aborting a whole
320
+ // keyframe mount. The §11 tolerant loader admits all of these shapes into
321
+ // the model, so the viewer is where they arrive.
322
+ if (!domNode || typeof domNode !== 'object') {
323
+ ctx.skipped++;
324
+ return null;
325
+ }
326
+ var doc = ctx.doc;
327
+ var node;
328
+ if (domNode.kind === 'text') {
329
+ node = doc.createTextNode(domNode.text == null ? '' : String(domNode.text));
330
+ } else if (domNode.kind === 'comment') {
331
+ node = doc.createComment(domNode.text == null ? '' : String(domNode.text));
332
+ } else {
333
+ var tag = String(domNode.tag == null ? '' : domNode.tag).toLowerCase();
334
+ if (!isUsableTag(tag)) {
335
+ ctx.skipped++;
336
+ return null;
337
+ }
338
+ node = instantiateElement(domNode, ctx, tag, parentNs);
339
+ }
340
+ if (!bind(ctx, domNode.id, node)) {
341
+ // The id belongs to the reconstruction root and `bind` refused to move it
342
+ // (see there). Refusing the binding means refusing the NODE: the walk
343
+ // already bound its children as it descended, and a node in the tree that
344
+ // no id resolves to breaks every reader that walks from the root —
345
+ // including `readTree`, the shared one — which is the same failure the
346
+ // refusal exists to prevent.
347
+ purgeSubtree(ctx, node);
348
+ ctx.patchFailures++;
349
+ return null;
350
+ }
351
+ return node;
352
+ }
353
+
354
+ function instantiateElement(domNode, ctx, tag, parentNs) {
355
+ var inert = INERT_TAGS[tag] === true;
356
+ var isFrame = tag === 'iframe';
357
+ var ns = namespaceFor(tag, parentNs);
358
+ // The FRAME's document, never the report's: a node created in the report's
359
+ // realm and adopted into the frame is a cross-realm object, and the whole
360
+ // point of the sandbox is that the reconstruction is made of the frame's own
361
+ // nodes. A `<template>` standing in for a script is an HTML element wherever
362
+ // the script sat, so the substitution lands in the XHTML namespace.
363
+ var created = inert ? 'template' : tag;
364
+ var createdNs = inert ? XHTML_NS : ns;
365
+ var el = createdNs === XHTML_NS
366
+ ? ctx.doc.createElement(created)
367
+ : ctx.doc.createElementNS(createdNs, created);
368
+
369
+ var skip = skipSetFor(tag);
370
+ applyAttrs(el, domNode.attrs || {}, skip, ctx);
371
+ // §4's `media_src` is the RESOLVED url the element actually loaded
372
+ // (`currentSrc`), where the recorded `src` attribute is whatever the page
373
+ // author wrote — routinely a relative path, which resolves to nothing in a
374
+ // `srcdoc` frame with no base URL. The resolved one wins, which is the same
375
+ // rule capture applies to `<img>` (snapshot.js `emittedAttrValue`). Media is
376
+ // rendered as badges and lane markers rather than played (design §7); this
377
+ // is only so the element has its shape.
378
+ //
379
+ // It obeys the SAME skip set as the recorded attributes (fix round 1). Until
380
+ // it did, a `media_src` on an iframe node reached the live DOM as `src` and
381
+ // chromium issued the request whenever the shell CSP was absent — measured.
382
+ // §12's network policy is supposed to hold STRUCTURALLY here, with nothing to
383
+ // request; `frame-src 'none'` is the belt, it lives in a file this module
384
+ // does not ship with, and three consumers build their own frames.
385
+ if (!(skip && skip.src === true)
386
+ && typeof domNode.media_src === 'string' && domNode.media_src) {
387
+ setFilteredAttr(el, 'src', domNode.media_src, ctx);
388
+ }
389
+ if (isFrame) el.setAttribute(PLACEHOLDER_ATTR, 'iframe');
390
+ // The shadow flag is re-stamped BY THE VIEWER from the recorded claim rather
391
+ // than passed through `applyAttrs`, which now refuses the name. Same DOM as
392
+ // before; the difference is that the flag in the reconstruction is one the
393
+ // viewer put there and no later patch can move or delete (see
394
+ // VIEWER_OWNED_ATTRS). The value is normalised to the empty string capture
395
+ // writes, so the chip's selector cannot be dodged by a value.
396
+ var recorded = domNode.attrs;
397
+ if (recorded && Object.prototype.hasOwnProperty.call(recorded, SHADOW_ATTR)) {
398
+ el.setAttribute(SHADOW_ATTR, '');
399
+ }
400
+
401
+ // §4's `canvas_size` is the BITMAP size, which is not an attribute and must
402
+ // not become one — it is what sizes the parent-owned offscreen canvas the
403
+ // snapshots composite into (design §3.1) and what the used-size-0 sizing
404
+ // repair pins from (design §3.3). Both live in the viewer client; recording it
405
+ // is this walk's, because this walk is the only place the annotation is in
406
+ // hand.
407
+ //
408
+ // The delete is not redundant with the set. `dom.add` OVERWRITES an id
409
+ // binding, because a remove+add pair carrying one id is a MOVE (pin M5), and
410
+ // this map is keyed by the same ids — so an id re-bound to a node with NO
411
+ // `canvas_size` would keep the previous node's bitmap size, and the viewer
412
+ // would size an offscreen canvas for an element that is not a canvas.
413
+ ctx.canvases.delete(domNode.id);
414
+ var size = domNode.canvas_size;
415
+ if (size && typeof size === 'object'
416
+ && typeof size.w === 'number' && typeof size.h === 'number') {
417
+ ctx.canvases.set(domNode.id, { w: size.w, h: size.h });
418
+ }
419
+
420
+ var childNs = childNamespaceOf(tag, createdNs);
421
+ var kids = domNode.children || [];
422
+ for (var i = 0; i < kids.length; i++) {
423
+ var child = instantiateNode(kids[i], ctx, childNs);
424
+ if (child) el.appendChild(child);
425
+ }
426
+ return el;
427
+ }
428
+
429
+ // The span-scoped state. One of these per keyframe span (design §4): the id map
430
+ // is rebuilt from scratch at every span restore, and everything a patch needs
431
+ // to resolve travels with it.
432
+ //
433
+ // `idOf` is the reverse index, and it ships rather than being rebuilt on demand
434
+ // because `dom.remove` has to purge the removed node AND its whole subtree from
435
+ // the map — matching `dom-player.js` and the fork's `removeFromMap`, so a later
436
+ // patch naming a purged descendant is caught rather than resolved against a
437
+ // detached node. Rebuilding it per removal is O(span) per patch; keeping it
438
+ // alongside costs one `Map` and is maintained in exactly one place (`bind`).
439
+ function newContext(doc) {
440
+ return {
441
+ doc: doc, root: null,
442
+ idMap: new Map(), idOf: new Map(), canvases: new Map(),
443
+ skipped: 0, patchFailures: 0,
444
+ };
445
+ }
446
+
447
+ function result(ctx, root) {
448
+ ctx.root = root;
449
+ return ctx;
450
+ }
451
+
452
+ /**
453
+ * Bind an id to a node, overwriting whatever either side held.
454
+ *
455
+ * The overwrite IS the contract (design §4, T3 Task 3 pin M5): a `dom.remove` +
456
+ * `dom.add` pair carrying the same id is a MOVE, and treating the second
457
+ * binding as a duplicate loses the node. Both directions are cleared first, so
458
+ * the two maps stay exact inverses of each other — the property the subtree
459
+ * purge depends on and a test pins over both fixtures. Note the guarantee is
460
+ * about the MAP: a bare re-add with no preceding remove leaves the old subtree
461
+ * in the document, still resolvable through its own ids, because nothing asked
462
+ * for it to be detached and inventing a detach would be the viewer editing the
463
+ * reconstruction.
464
+ *
465
+ * ONE EXCEPTION, added in fix round 1 (review I-1): the id the mount bound to
466
+ * the reconstruction ROOT is not movable. A `dom.add` carrying it — at the
467
+ * subtree's own root or buried in its children — otherwise pointed the root id
468
+ * at an attacker-chosen element with nothing counted, which decides what Task
469
+ * 7's `exists`/`attr:<name>` read, what an `anchor.node` naming the root
470
+ * resolves to, and what the §8 camera chain measures; and it dropped
471
+ * `mount.root` out of `idOf`, so every reader that walks from the root threw
472
+ * one layer up. Unreachable from CH capture, reachable from any foreign file.
473
+ *
474
+ * @returns {boolean} whether the binding was made
475
+ */
476
+ function bind(ctx, id, node) {
477
+ var prev = ctx.idMap.get(id);
478
+ if (prev !== undefined && prev !== node) {
479
+ if (prev === ctx.root) return false;
480
+ ctx.idOf.delete(prev);
481
+ }
482
+ var prevId = ctx.idOf.get(node);
483
+ if (prevId !== undefined && prevId !== id) ctx.idMap.delete(prevId);
484
+ ctx.idMap.set(id, node);
485
+ ctx.idOf.set(node, id);
486
+ return true;
487
+ }
488
+
489
+ // Drop a node and every descendant from both maps.
490
+ //
491
+ // `template.content` is walked as well as `childNodes` for the realm reason
492
+ // `INERT_TAGS` records: happy-dom routes `appendChild` on a template into
493
+ // `content`, browsers keep it in `childNodes`. Walking both leaves the same map
494
+ // in every realm, which is the same determinism argument the name filters rest
495
+ // on. Deleting an id twice is a no-op, so the overlap costs nothing.
496
+ function purgeSubtree(ctx, node) {
497
+ var id = ctx.idOf.get(node);
498
+ if (id !== undefined) {
499
+ ctx.idOf.delete(node);
500
+ // Delete only the binding this node actually OWNS. Given the inversion
501
+ // `bind` maintains, `idMap.get(id) === node` always holds — so the test is
502
+ // dead weight on a healthy map and the whole point on a broken one: without
503
+ // it, a node whose reverse entry disagrees takes an innocent node's binding
504
+ // down with it, turning a loud inconsistency into a quiet one. Pinned by
505
+ // injection rather than left as a defensive line (review I-2, H3).
506
+ if (ctx.idMap.get(id) === node) {
507
+ ctx.idMap.delete(id);
508
+ ctx.canvases.delete(id);
509
+ }
510
+ }
511
+ var kids = node.childNodes || [];
512
+ for (var i = 0; i < kids.length; i++) purgeSubtree(ctx, kids[i]);
513
+ var content = node.content;
514
+ if (content && content.childNodes) {
515
+ var inner = content.childNodes;
516
+ for (var j = 0; j < inner.length; j++) purgeSubtree(ctx, inner[j]);
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Instantiate a DomNode tree as a DETACHED subtree of `doc`.
522
+ *
523
+ * @param {object} domNode a spec §4 DomNode tree
524
+ * @param {Document} doc the frame's document — every node is created in it
525
+ * @returns {{root: Node|null, doc: Document, idMap: Map<number, Node>,
526
+ * idOf: Map<Node, number>, canvases: Map<number, {w, h}>,
527
+ * skipped: number, patchFailures: number}}
528
+ * the span state `applyPatch` extends. `canvases` is the §4 `canvas_size`
529
+ * annotation, which no DOM read can recover, keyed by the same ids as
530
+ * `idMap`.
531
+ *
532
+ * The two counters answer different questions and both belong in the
533
+ * `counters.patchFailures` surface that already drives the "recorded
534
+ * change(s) could not be reapplied" chip: `skipped` is *the file said
535
+ * something no DOM here could hold* (a tag or attribute name outside the
536
+ * Name production, a null child), `patchFailures` is *a reference the span
537
+ * could not honour* (an id the map does not hold, a `before` that is not a
538
+ * child, a target of the wrong kind, a refused re-bind of the root).
539
+ *
540
+ * THE RETURNED OBJECT IS THE LIVE STATE, not a snapshot: `applyPatch` keeps
541
+ * incrementing these counters on the same object, so read them as properties
542
+ * (`mount.skipped`) at the moment you need them. Destructuring at mount time
543
+ * captures the numbers as they were before any patch applied.
544
+ */
545
+ function instantiateTree(domNode, doc) {
546
+ var ctx = newContext(doc);
547
+ return result(ctx, instantiateNode(domNode, ctx, null));
548
+ }
549
+
550
+ /**
551
+ * Mount a keyframe into the frame's body, with the body-root split.
552
+ *
553
+ * A root whose tag is `body` — CH's ordinary case, since the observed root
554
+ * defaults to `document.body`, and jsPsych's too — has its ATTRIBUTES applied
555
+ * to the frame's own `<body>` and its children instantiated inside it, with
556
+ * the root id bound to that body. Instantiating a second `<body>` inside the
557
+ * first would break the `height: 100%` chain a wiped display element depends
558
+ * on and would put the recorded body's styles on a node the page's CSS does
559
+ * not match. Any other root is instantiated as a child of the cleared body.
560
+ *
561
+ * This is also the rebuild: the body's previous children and attributes are
562
+ * removed first, so a span restore is `mountTree(...)` and nothing else. The
563
+ * frame itself survives — `srcdoc` is written once per mount (design §4), so
564
+ * no `onload` round trip stands between a backward seek and a readable DOM.
565
+ *
566
+ * CARRY FOR TASK 3: on the body-root path the root id is bound to the frame's
567
+ * OWN `<body>`. A `dom.remove` naming that id must skip and count rather than
568
+ * call `.remove()`, which would detach the element every later mount and patch
569
+ * depends on and leave the reconstruction nowhere to go.
570
+ *
571
+ * @param {object|null} domNode the span keyframe's `initial_dom`; null clears
572
+ * the body and mounts nothing, which is the trace-tier and the
573
+ * nothing-to-restore case
574
+ * @param {Element} body the frame's `<body>`
575
+ * @param {Document} doc the frame's document
576
+ * @returns {object} the span state, as `instantiateTree` documents it
577
+ */
578
+ function mountTree(domNode, body, doc) {
579
+ while (body.firstChild) body.removeChild(body.firstChild);
580
+ var live = body.attributes;
581
+ for (var i = live.length - 1; i >= 0; i--) body.removeAttribute(live[i].name);
582
+
583
+ var ctx = newContext(doc);
584
+ // A null keyframe is the LEGITIMATE case — trace tier, or a restore with
585
+ // nothing to mount — so it clears and reports no skip. Anything else that is
586
+ // not an object is a malformed keyframe and is counted.
587
+ if (domNode == null) return result(ctx, null);
588
+ if (typeof domNode !== 'object') {
589
+ ctx.skipped++;
590
+ return result(ctx, null);
591
+ }
592
+
593
+ var tag = domNode.kind === 'element' || domNode.kind == null
594
+ ? String(domNode.tag == null ? '' : domNode.tag).toLowerCase() : null;
595
+ if (tag !== 'body') {
596
+ var root = instantiateNode(domNode, ctx, null);
597
+ if (root) body.appendChild(root);
598
+ return result(ctx, root);
599
+ }
600
+
601
+ applyAttrs(body, domNode.attrs || {}, null, ctx);
602
+ bind(ctx, domNode.id, body);
603
+ // Recorded BEFORE the children walk, not just by `result()` at the end, so a
604
+ // keyframe whose descendant repeats the root's id cannot re-bind it away from
605
+ // the frame's own body mid-mount — the mount-time half of `bind`'s exception.
606
+ ctx.root = body;
607
+ var kids = domNode.children || [];
608
+ for (var j = 0; j < kids.length; j++) {
609
+ var child = instantiateNode(kids[j], ctx, null);
610
+ if (child) body.appendChild(child);
611
+ }
612
+ return result(ctx, body);
613
+ }
614
+
615
+ // ── spec §5.1 patch application ────────────────────────────────────────────
616
+ //
617
+ // TOLERANT, COUNTED, SURFACED (design §4). A patch naming an id the map does
618
+ // not hold is skipped and counted into `patchFailures`, never thrown on. This
619
+ // is a DELIBERATE divergence from `tests/replay/support/dom-player.js`, which
620
+ // throws on the same input: the test player's job is to catch mapper bugs, the
621
+ // viewer's job is to show an analyst as much of an unrepeatable session as
622
+ // survives. The consequence is recorded and routed — a tolerant viewer cannot
623
+ // detect a producer emitting dangling references, which is why T7 owes a
624
+ // dangling-reference negative fixture (design §14 risk 2).
625
+ //
626
+ // What is NOT counted: an attribute refused by the §12 filters. Instantiation
627
+ // drops the same names silently, so counting them here would light up the
628
+ // "recorded change(s) could not be reapplied" chip for a filter working exactly
629
+ // as designed. A dropped `on*` handler leaves the visual reconstruction right;
630
+ // an unresolvable id means it is wrong.
631
+
632
+ // Every caller tests the result for falsiness, so an id the map does not hold
633
+ // arrives as `undefined` and reads the same as a missing node.
634
+ function resolve(mount, id) {
635
+ return mount.idMap.get(id);
636
+ }
637
+
638
+ function tagNameOf(node) {
639
+ return node && node.tagName ? String(node.tagName).toLowerCase() : '';
640
+ }
641
+
642
+ function applyAdd(patch, mount) {
643
+ var parent = resolve(mount, patch.parent);
644
+ if (!parent || parent.nodeType !== ELEMENT_NODE) {
645
+ mount.patchFailures++;
646
+ return;
647
+ }
648
+ // The inserted subtree inherits the LIVE parent's namespace, so a `dom.add`
649
+ // into an SVG subtree does not silently produce XHTML children that lay out
650
+ // at 0×0 — and an add into an SVG HTML-integration point produces HTML
651
+ // again. `dom-player.js` carries the same rule; re-sync both when either
652
+ // moves.
653
+ var childNs = childNamespaceOf(tagNameOf(parent), parent.namespaceURI);
654
+ var node = instantiateNode(patch.node, mount, childNs);
655
+ if (!node) return; // already counted into `skipped`
656
+
657
+ var ref = null;
658
+ if (patch.before !== null && patch.before !== undefined) {
659
+ var anchor = mount.idMap.get(patch.before);
660
+ // A `before` the map does not hold, or one that is not a child of this
661
+ // parent, cannot be honoured: the node is appended instead. Counted,
662
+ // because the recorded POSITION was lost even though the node survived —
663
+ // the reconstruction is knowingly approximate at that point.
664
+ if (anchor !== undefined && anchor.parentNode === parent) ref = anchor;
665
+ else mount.patchFailures++;
666
+ }
667
+
668
+ try {
669
+ parent.insertBefore(node, ref);
670
+ } catch (err) {
671
+ // A hierarchy the DOM refuses. The subtree is unbound again rather than
672
+ // left in the map, where a later patch would resolve it against a node
673
+ // nothing can see.
674
+ mount.patchFailures++;
675
+ purgeSubtree(mount, node);
676
+ }
677
+ }
678
+
679
+ function applyRemove(patch, mount) {
680
+ var node = resolve(mount, patch.node);
681
+ if (!node) {
682
+ mount.patchFailures++;
683
+ return;
684
+ }
685
+ // The reconstruction root is not removable, under either root shape — the
686
+ // other half of the invariant `bind` protects. On the body-root path (design
687
+ // §4's split) the root IS the frame's own `<body>`, so a blind `.remove()`
688
+ // detaches the element every later mount and patch depends on; on any other
689
+ // path it is the node every reader walks from. One predicate for both, so
690
+ // there is no disjunct a test cannot reach.
691
+ if (node === mount.root) {
692
+ mount.patchFailures++;
693
+ return;
694
+ }
695
+ purgeSubtree(mount, node);
696
+ if (node.parentNode) node.parentNode.removeChild(node);
697
+ }
698
+
699
+ function applyAttr(patch, mount) {
700
+ var el = resolve(mount, patch.node);
701
+ if (!el || el.nodeType !== ELEMENT_NODE) {
702
+ mount.patchFailures++;
703
+ return;
704
+ }
705
+ var name = String(patch.name == null ? '' : patch.name);
706
+ if (patch.value === null || patch.value === undefined) {
707
+ // ONE name check on the removal path, and it is the same predicate the set
708
+ // path uses. `removeAttribute` validates nothing — browsers by spec,
709
+ // happy-dom measured — so removing a name this module would never have SET
710
+ // is a harmless no-op and needs no guard; what DOES need one is the
711
+ // viewer's own flags, which a recording can strip as well as forge. Task 3
712
+ // left this line to Task 4 because the detector that reads the flags back
713
+ // lands there.
714
+ if (isViewerOwnedAttr(name)) return;
715
+ el.removeAttribute(name);
716
+ return;
717
+ }
718
+ // ONE §12 gate on the set path, and it is the gate instantiation uses, so a
719
+ // keyframe and a patch can never disagree about what an element may carry.
720
+ setFilteredAttr(el, name, patch.value, mount);
721
+ }
722
+
723
+ function applyText(patch, mount) {
724
+ var node = resolve(mount, patch.node);
725
+ if (!node) {
726
+ mount.patchFailures++;
727
+ return;
728
+ }
729
+ // §5.1's `dom.text` addresses a text or comment node. An element has no
730
+ // character data, and assigning to `nodeValue` on one is a silent no-op —
731
+ // counting is the honest answer, and it is where the two players differ
732
+ // hardest: the strict one writes `.data` on whatever it resolved.
733
+ if (node.nodeType !== TEXT_NODE && node.nodeType !== COMMENT_NODE) {
734
+ mount.patchFailures++;
735
+ return;
736
+ }
737
+ node.nodeValue = patch.text == null ? '' : String(patch.text);
738
+ }
739
+
740
+ /**
741
+ * Apply one spec §5.1 patch to a mounted span.
742
+ *
743
+ * @param {object} patch a `dom.add` / `dom.remove` / `dom.attr` / `dom.text`
744
+ * event; anything else is left alone
745
+ * @param {object} mount the state `mountTree`/`instantiateTree` returned
746
+ * @returns {boolean} whether this event was a DOM patch — so a caller's
747
+ * vocabulary dispatch (§5.2-§5.8, in the viewer client) can fall through on
748
+ * `false`
749
+ * without needing its own list of the four types
750
+ */
751
+ function applyPatch(patch, mount) {
752
+ if (!patch) return false;
753
+ var type = patch.type;
754
+ if (type === 'dom.add') applyAdd(patch, mount);
755
+ else if (type === 'dom.remove') applyRemove(patch, mount);
756
+ else if (type === 'dom.attr') applyAttr(patch, mount);
757
+ else if (type === 'dom.text') applyText(patch, mount);
758
+ else return false;
759
+ return true;
760
+ }
761
+
762
+ /**
763
+ * Apply a time-ordered slice of a segment's events, in order, skipping the
764
+ * ones that are not DOM patches.
765
+ *
766
+ * @param {object[]} patches
767
+ * @param {object} mount
768
+ * @returns {object} the same mount state, for chaining
769
+ */
770
+ function applyPatches(patches, mount) {
771
+ var list = patches || [];
772
+ for (var i = 0; i < list.length; i++) applyPatch(list[i], mount);
773
+ return mount;
774
+ }
775
+
776
+ // ONE line of ESM syntax, last, so the build can strip it with a single
777
+ // replace and concatenate the rest into the report's viewer script. Keep it
778
+ // that way — the header explains why, and the test suite fails if it drifts.
779
+ export { instantiateTree, mountTree, applyPatch, applyPatches };