Architecture Decision Record · Canvas / Third-Party-Embed

Salesforce-Marketing-Cloud-Formulare als Canvas-SDC mit Host-Script-Hoisting und CMP-Domain-Consent

accepted 2026-06-17
TL;DR: Salesforce-Marketing-Cloud-Formulare werden über eine neue SDC byte_theme:salesforce-marketing-form mit einem Prop embed_code eingebettet. Der Prop nutzt pattern: '(.|\r?\n)*' und wird so von Canvas als ungefilterte string_textarea gespeichert (kein filter_html, das <script> strippen würde); das Twig gibt ihn mit |raw aus. Die published Canvas-Seite ist server-seitig gerendert, daher landet der Snippet im HTML-Quelltext und die Scripts laufen. Das konstante Host-Loader-Script wird in RenderCallbacks::preRenderComponent aus dem Snippet gestrippt und stattdessen als dedupliziertes Header-Library byte_theme/salesforce-forms-host einmal pro Seite geladen; das pro-Formular-Lightning-Out-Script (inkl. onload-Handshake) und das <fragment-…>-Element bleiben inline. Consent läuft CMP-agnostisch über Domain-Blocking auf solcom.my.site.com, konfiguriert in der Usercentrics-Konsole (F-300). Achtung: Es gibt aktuell kein aktives Gating — Klaro ist dormant im Silent-Mode, Usercentrics noch nicht live. Die Komponente darf daher erst dann auf eine öffentliche Seite, wenn das Domain-Blocking konfiguriert ist; sonst feuert das Tracking ungated. Die Sicherheit ist hier operativ, nicht im Code erzwungen.

Kontext

SOLCOM will Salesforce-Marketing-Cloud-Formulare (Newsletter-/Marketing-Anmeldungen) in Canvas-Seiten einbetten. Der von Salesforce erzeugte Embed-Snippet besteht aus drei Teilen:

  1. einem konstanten Host-Loader-Script external-forms-host.min.js (identisch für jedes Formular dieser SF-Org);
  2. einem pro-Formular-Lightning-Out-Script mit wechselnder ID und einem inline onload="window.sfdc_forms?sfdc_forms.init():window.sfdc_forms={loReady:!0}"-Handshake;
  3. einem Custom-Element <fragment-…> mit pro-Formular eindeutigem Tag-Namen — der eigentliche Form-Container.

Das Formular trägt data-web-tracking="true" und lädt Code von solcom.my.site.com — es tracked aktiv und ist damit consent-pflichtig.

Drei harte technische Randbedingungen wurden vorab verifiziert und bestimmen die Architektur:

  • Canvas-Text-Filter strippen <script>. Alle Text-Formate (canvas_html_block etc.) jagen Prop-Inhalte durch filter_html; ein contentMediaType: text/html-Prop würde den Snippet zerstören. Auch Slot-Defaults werden über Xss::filterAdmin() gefiltert.
  • Per innerHTML injizierte <script>-Tags werden vom Browser nicht ausgeführt. Die Scripts müssen server-seitig in den HTML-Quelltext gerendert werden. Die published Canvas-Seite ist genau das: ComponentTreeItemList::toRenderable() rendert den Component-Tree server-seitig via Twig (Client-Hydration nur im Editor).
  • Kein generischer XSS-Filter auf SDC-Props. ComponentElement kopiert #props ohne erneute Filterung in den Twig-Context; ein string-Prop ohne text/html-MediaType bleibt verbatim erhalten.

Der Product Owner hat die Topologie-Entscheidung getroffen: SDC (nicht Block-Plugin). Zusätzlich wurde festgelegt, dass das wiederkehrende Host-Script zentral in den <head> gehört und das Projekt für Consent auf Usercentrics wechselt (Story F-300; bislang nur ein dormanter Klaro im Silent-Mode).

Entscheidung

1. SDC mit einem ungefilterten embed_code-Prop

Die neue SDC byte_theme:salesforce-marketing-form hat genau einen Prop embed_code vom Typ string mit pattern: '(.|\r?\n)*'. Dieses Pattern lässt Canvas den Prop als string_long-Feld mit string_textarea-Widget und Expression …␟value (nicht …␟processed) speichern — also ohne allowed_formats/filter_html. Der Redakteur fügt den kompletten Salesforce-Snippet in die Textarea ein; <script> und <fragment-…> überleben verbatim. Das Twig gibt den Wert mit |raw innerhalb eines <div class="salesforce-marketing-form"> aus. {% if embed_code %} verhindert ein leeres Wrapper-Div.

2. Host-Script-Hoisting via RenderCallbacks::preRenderComponent

Das konstante Host-Loader-Script ist für jedes Formular identisch und soll genau einmal pro Seite laden. Statt es inline pro SDC-Instanz zu duplizieren, wird es in RenderCallbacks::preRenderComponent (demselben bestehenden #pre_render-Seam, der schon den Hero-LCP-Preload macht) aus dem Snippet gestrippt und als Library byte_theme/salesforce-forms-host (header: true, type: external, async) attached. Drupal dedupliziert Libraries automatisch → ein <head>-Eintrag, egal wie viele Formulare auf der Seite liegen. Das pro-Formular-Lightning-Out-Script und das <fragment-…> bleiben inline, der onload-Handshake also intakt.

Wichtiges Render-Pipeline-Detail: Cores ComponentElement-pre_render läuft vor diesem angehängten Callback und backt #props bereits in den inline-template-Context. Eine Mutation von #props käme zu spät — der Strip greift daher am bereits gebauten inline-template['#context']['embed_code']. #attached bubbelt unabhängig von der Callback-Reihenfolge hoch. Der Callback ist auf die eine Komponente gegated, damit Hero & Co. unberührt bleiben (ein Prepend vor Core hätte die Hero-Logik in der Reihenfolge verändert und ist deshalb verworfen).

3. CMP-agnostisches Consent-Gating über Domain-Blocking

Die externen Scripts werden allein über ihre Domain solcom.my.site.com gegated — es gibt keine CMP-spezifischen data-usercentrics/data-name-Attribute in der SDC. Zielzustand (Variante A): ein Usercentrics-Data-Processing-Service „Salesforce Marketing Cloud" mit Domain-Auto-Blocking auf solcom.my.site.com, konfiguriert in der Usercentrics-Konsole — kein Code-Change an der SDC. Der Wechsel des CMP (heute dormanter Klaro, künftig Usercentrics) bleibt damit eine reine Konsolen-/Config-Sache.

4. Trust-Boundary für |raw

embed_code ist ein Trusted-HTML-Sink, äquivalent zum Full-HTML-Textformat. Die Sicherheitseigenschaft gilt ausschließlich, solange das Canvas-Editing auf Rollen mit redaktioneller Vertrauensstufe beschränkt bleibt. Verifiziert (Stand dieses ADR): nur content_editor und content_manager tragen die Canvas-Editing-Rechte; keine nicht-vertrauenswürdige Rolle hat Zugriff. Eine spätere Ausweitung dieser Rechte auf eine offenere Rolle würde diesen Prop zu einem Stored-XSS-Vektor machen und ist nur zulässig, wenn der Prop dann anders abgesichert wird.

Abgelehnte Alternativen

Block-Plugin statt SDC

Ein Custom-Block mit Config-Formular (Textarea oder strukturierte Felder), der den Snippet im build() emittiert. Technisch ebenfalls tragfähig (umgeht Filter, kann via #attached['html_head'] server-rendern) und war die ursprüngliche Empfehlung. Vom Product Owner zugunsten einer SDC verworfen — die SDC fügt sich in die bestehende byte_theme-Komponenten-Vokabularik (77 SDCs) ein und ist im Canvas-Editor wie jede andere Komponente platzierbar.

contentMediaType: text/html-Prop mit Canvas-Text-Editor

Ein Rich-Text-Prop wie bei der text-Komponente. Abgelehnt, weil Canvas solche Props über ein text_long-Feld mit allowed_formats: [canvas_html_block] speichert und filter_html <script> sowie das Custom-Element entfernt — der Snippet wäre nutzlos.

Snippet als Slot statt Prop

Den Embed-Code als Slot-Inhalt übergeben. Abgelehnt, weil Canvas Slot-String-Defaults über Xss::filterAdmin() rendert (ComponentTreeItemList), was <script> ebenfalls strippt.

Genereller HTML-Parser zum Aufsplitten des Snippets

Den Snippet per DOMDocument vollständig parsen und alle Scripts programmatisch in den <head> heben. Abgelehnt als Over-Engineering: nur das eine konstante Host-Script muss zentralisiert werden (stabile, bekannte Form → gezielter Regex genügt); das pro-Formular-Script muss ohnehin inline bei seinem Fragment und seinem onload bleiben.

CMP-spezifische Consent-Attribute in der SDC

type="text/plain" data-usercentrics="…" direkt im Twig setzen. Abgelehnt, weil das die SDC an ein konkretes CMP bindet; Domain-Blocking erreicht dasselbe CMP-seitig und überlebt den Klaro→Usercentrics-Wechsel ohne Code-Change.

Konsequenzen

Positiv:

  • Redaktionell pflegbar ohne Code-Change pro neuem Formular — ein Paste in die Textarea genügt.
  • Host-Script lädt genau einmal pro Seite im <head> (Library-Dedup), auch bei mehreren Formularen.
  • Der onload-Handshake des Lightning-Out-Scripts bleibt unangetastet, weil dieses Script inline bei seinem Fragment bleibt.
  • CMP-agnostisch: der Klaro→Usercentrics-Wechsel ist reine Konsolen-Config.
  • Konsistent mit dem bestehenden Theme-Muster (RenderCallbacks-Hoisting wie beim Hero-LCP-Preload).

Risiken / Kosten:

  • Trust-Boundary: |raw auf einem redaktionellen Prop ist nur sicher, solange Canvas-Editing auf content_editor/content_manager beschränkt bleibt (siehe Entscheidung 4).
  • Regex-/URL-Kopplung: Der Dateiname external-forms-host.min.js steht zweimal — im Strip-Regex (RenderCallbacks::stripSalesforceHostScript) und in der Library-URL (byte_theme.libraries.yml). Beide sind per Kommentar gegenseitig verlinkt; benennt Salesforce den Loader um, müssen beide nachgezogen werden, sonst lädt das Host-Script doppelt (inline + Library).
  • Kein Live-Preview: Ein leerer/unvollständiger Snippet rendert lautlos nichts (kein Fehler). Die Component-Description weist darauf hin.

Verifikation: 3 Kernel-Render-Tests (SalesforceMarketingFormComponentTest) über den echten #type => component-Pfad — raw-Render des Fragments, Leer-Fall, Host-Strip + Library-Attach + onload-Erhalt. Zusätzlich per realem Render (drush php:script) und Canvas-Tree-Preview (tool_api_preview_canvas_page, keine validation_errors) bestätigt; die generierte canvas.component.sdc.byte_theme.salesforce-marketing-form.yml belegt field_type: string_long + …␟value (ungefiltert).

Offene Punkte

F-300 / Usercentrics-Aktivierung (Blocker für Live-Schalten). Die Komponente hat kein eigenes Consent-Gating im Code: die Library byte_theme/salesforce-forms-host und der inline Lightning-Out-Snippet laden, sobald die Komponente auf einer Seite steht — das Gating hängt vollständig an der CMP-Domain-Config. Da heute weder Klaro (dormant, Silent-Mode) noch Usercentrics solcom.my.site.com blockt, ist der aktuelle Stand ungated — die Komponente darf erst dann auf eine öffentliche Seite, wenn das Domain-Blocking konfiguriert und im Browser verifiziert ist. Andernfalls feuert data-web-tracking="true" ohne Consent. Dies ist ein Live-Launch-Gate, kein Komponenten-Merge-Gate.
onload-Erhalt nach Consent (Browser-Verify nach Usercentrics). Manche Auto-Blocker bauen ein Script aus seiner src neu und verwerfen dabei inline onload. Sobald Usercentrics live ist, muss im Browser geprüft werden, dass der Lightning-Out-onload-Handshake den Re-Inject überlebt — sonst initialisiert das Formular auch nach Consent nie.
Figma-MAPPING-Eintrag. docs/design/MAPPING.html hat keinen Eintrag für salesforce-marketing-form. Für eine Third-Party-Integration-Embed ist das Fehlen eines Figma-Pendants begründbar — sollte aber als bewusste Lücke im MAPPING vermerkt werden.