diff --git a/DeepDrftPublic.Client/Common/SeoEnvironment.cs b/DeepDrftPublic.Client/Common/SeoEnvironment.cs
new file mode 100644
index 0000000..a9e017d
--- /dev/null
+++ b/DeepDrftPublic.Client/Common/SeoEnvironment.cs
@@ -0,0 +1,33 @@
+using Microsoft.AspNetCore.Components;
+
+namespace DeepDrftPublic.Client.Common;
+
+///
+/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must
+/// not be crawled, so the default robots directive is environment-gated: index,follow only in
+/// Production, noindex,nofollow everywhere else. A per-page override
+/// still wins — this only sets the default.
+///
+///
+/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the
+/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve
+/// the same flag. The WASM assembly has no IWebHostEnvironment (config comes from the server). This
+/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from
+/// IWebHostEnvironment.IsProduction()) and [PersistentState] rounds to the client, so both
+/// passes resolve the identical value. SeoHead injects this rather than an environment dependency,
+/// honouring the no-environment-in-the-component constraint.
+///
+///
+public class SeoEnvironment
+{
+ ///
+ /// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to
+ /// false so the fail-safe is "do not index" — a missing bridge never accidentally opens a
+ /// non-production site to crawlers.
+ ///
+ [PersistentState]
+ public bool IsProduction { get; set; }
+
+ /// The environment-gated default robots directive. Explicit page values override this.
+ public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow";
+}
diff --git a/DeepDrftPublic.Client/Common/SeoJsonLd.cs b/DeepDrftPublic.Client/Common/SeoJsonLd.cs
index ffa5b8c..9c256fa 100644
--- a/DeepDrftPublic.Client/Common/SeoJsonLd.cs
+++ b/DeepDrftPublic.Client/Common/SeoJsonLd.cs
@@ -22,13 +22,30 @@ public static class SeoJsonLd
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// schema.org keys are PascalCase ("@type", "byArtist", "datePublished"); JsonPropertyName drives
// each. Encoder relaxed so the JSON sits inline in HTML without over-escaping apostrophes etc.
+ // Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the
+ // body is injected into the / < must not survive raw in the inline script body —
+ // that is a breakout/XSS vector. The escaped < keeps the JSON parseable while neutralising it.
+ var release = Release(ReleaseMedium.Cut, title: "Evil");
+ var body = SeoModel.ForRelease(Options, release).JsonLd;
+
+ Assert.That(body, Is.Not.Null);
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Does.Not.Contain("<"), "no raw < may appear in the inline JSON-LD body");
+ Assert.That(body, Does.Not.Contain(">"), "no raw > may appear in the inline JSON-LD body");
+ Assert.That(body, Does.Contain("\\u003C"), "< must be emitted as its \\u003C JSON escape");
+ // The escaped body still parses as JSON and round-trips the original value.
+ var node = Parse(body);
+ Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Evil"));
+ });
+ }
+
+ [Test]
+ public void ForRelease_TitleWithAmpersand_EscapesAmpersand_StillParses()
+ {
+ // The title is serialized into the JSON-LD `name`, so an ampersand there exercises the & escape.
+ var release = Release(ReleaseMedium.Mix, title: "Sound & Vision");
+ var body = SeoModel.ForRelease(Options, release).JsonLd;
+
+ Assert.That(body, Is.Not.Null);
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Does.Not.Contain("&"), "no raw & may appear in the inline JSON-LD body");
+ Assert.That(body, Does.Contain("\\u0026"), "& must be emitted as its \\u0026 JSON escape");
+ Assert.That(Parse(body).GetProperty("name").GetString(), Is.EqualTo("Sound & Vision"));
+ });
+ }
}