File

libs/core-forms/src/lib/backend-integration-plugin/backend-integration-plugin.ts

Index

Methods

Constructor

constructor()

Methods

registerActions
registerActions()
Returns : void


type: plugin title: backend-integration-plugin description: This plugin transforms the input data of a dynamic form and sends it to the CoreForms backend. In addition, it fetches the sitekey that is required for the Turnstile. runtimeCompatible: true

⚠️ This plugin is for internal usage only!

How to use the CoreForms backend plugin?

  1. In your pfe.jsonc file, add the CORE_FORMS_BACKEND_INTEGRATION to the onPageLeaveActions of the page where the data should be sent to the backend. There is no action provided for catching the sitekey of the Turnstile. This is done automatically by the plugin as soon as it has been initialized. The sitekey will be stored in the $._taly.turnstileSitekey key of the PFE state. If the request for the sitekey fails, turnstileSitekey will be set to an empty string.
"pages": [
  {
    "pageId": "some-page-id",
    "onPageLeaveActions": [
      {
        "type": "CORE_FORMS_BACKEND_INTEGRATION"
      }
    ]
  }
]
  1. Add the @allianz/taly-core-forms package to the libraries array of the pages.jsonc file
"libraries": [
  {
    "package": "@allianz/taly-core-forms",
    "version": "CURRENT_VERSION"
  }
]
  1. Add the BackendIntegrationPluginModule with the options below to the plugins array of the pages.jsonc file. talyPrefix describes how the dynamic form ids have to start to be recognized by the plugin.
"plugins": [
  {
    "package": "@allianz/taly-core-forms",
    "modules": [
      {
        "name": "BackendIntegrationPluginModule",
        "options": {
          "formId": "1740391937943_v3",
          "formName": "my-form",
          "tenant": "sandbox",
          "site": "coreforms",
          "instance": "emeaprd",
          "talyTurnstileFormfieldId": "turnstile-token",
          "talyPrefix": "core-forms-"
        }
      }
    ]
  }
],
  1. Ensure that exactly one Turnstile custom component is added to your form with the id (talyTurnstileFormfieldId) you specified in step 2. This Turnstile custom component has to be a child of a dynamic form with an id starting with the talyPrefix you defined in step 2.

Hidden Fields

The backend integration plugin supports hidden fields that can be automatically included when submitting form data. Hidden fields allow you to capture additional context information - either through manual configuration with static values or automatic capture of browser/context data.

Hidden fields are configured through the hiddenFields option in the plugin configuration. See the Complete Example section below for a full configuration example.

Field Types

Manual Hidden Fields

For fields with static values that you want to include with every form submission:

{
  "type": "static",
  "key": "source",
  "value": "landing-page-form"
}
  • type: Must be "static" for manual fields
  • key: Custom key name for the hidden field
  • value: Static value to include

Automatic Hidden Fields

The plugin automatically captures browser and context information:

Referrer (referrer)

Captures the URL of the page that linked to the current page:

{
  "type": "referrer",
  "key": "sourceUrl"
}
  • type: Must be "referrer"
  • key: (Optional) Custom key name for the hidden field. If not provided, defaults to "referrer"

Example: If the user navigated from https://example.com/previous-page, this would send {"sourceUrl": "https://example.com/previous-page"}.

User Agent (userAgent)

Captures the browser's user agent string:

{
  "type": "userAgent",
  "key": "browser"
}
  • type: Must be "userAgent"
  • key: (Optional) Custom key name for the hidden field. If not provided, defaults to "userAgent"

Example: This would send something like {"browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36..."}.

URL Parameters & Cookies

Individual URL Parameter (urlParam)

Captures a specific URL query parameter:

{
  "type": "urlParam",
  "name": "utm_source",
  "key": "marketingSource"
}
  • type: Must be "urlParam"
  • name: The exact name of the URL parameter to capture (e.g., "utm_source", "campaign_id")
  • key: (Optional) The key name to use when sending this field to the backend. If not provided, the name will be used by default

Example: For URL https://example.com/form?utm_source=email&utm_campaign=summer, this configuration would capture only utm_source and send it as {"marketingSource": "email"}.

Captures a specific browser cookie value:

{
  "type": "cookie",
  "name": "marketing_consent",
  "key": "consentStatus"
}
  • type: Must be "cookie"
  • name: The exact name of the cookie to capture
  • key: (Optional) The key name to use when sending this field to the backend. If not provided, the name will be used by default

Example: If cookie contains "marketing_consent=granted; session=abc123", this configuration would capture only marketing_consent and send it as {"consentStatus": "granted"}.

Security: Blocklist Filtering

When using cookie or urlParam hidden fields, the framework automatically blocks sensitive parameters to prevent accidental exposure of credentials, tokens, or session data.

Default blocklist (automatically applied):

The framework blocks parameters matching these patterns:

  • password, pwd - Any password variants
  • secret - Any secret (client_secret, api_secret, secretKey, etc.)
  • token - Any token (auth_token, access_token, session_token, etc.)
  • csrf, xsrf - CSRF/XSRF tokens
  • session - Session identifiers
  • auth - Authentication related parameters (including authKey)
  • jwt - JSON Web Tokens
  • bearer - Bearer tokens
  • apiKey, api_key, privateKey, private_key - API and private keys

All other parameters are allowed by default.

Custom Blocklist

To block additional parameters beyond the framework defaults, provide a custom blocklist:

{
  "name": "BackendIntegrationPluginModule",
  "options": {
    "formId": "123456789",
    "tenant": "sandbox",
    "site": "coreforms",
    "instance": "emeaprd",
    "talyTurnstileFormfieldId": "turnstile-token",
    "talyPrefix": "core-forms-",
    "blocklist": ["internal_code", "admin_flag", /^debug_.+$/]
  }
}

Behavior:

  • Your custom blocklist is merged with the default blocklist
  • Both default and custom patterns are applied
  • Supports exact string matches and regex patterns
  • Blocked parameters will be skipped with an error logged to the console

Complete Example

"plugins": [
  {
    "package": "@allianz/taly-core-forms",
    "modules": [
      {
        "name": "BackendIntegrationPluginModule",
        "options": {
          "formId": "123456789",
          "tenant": "sandbox",
          "site": "coreforms",
          "instance": "emeaprd",
          "talyTurnstileFormfieldId": "turnstile-token",
          "talyPrefix": "core-forms-",
          "hiddenFields": [
            {
              "type": "static",
              "key": "formSource",
              "value": "contact-us-page"
            },
            {
              "type": "static",
              "key": "version",
              "value": "v2.1"
            },
            {
              "type": "referrer"
            },
            {
              "type": "userAgent"
            },
            {
              "type": "urlParam",
              "name": "utm_source",
              "key": "marketingSource"
            },
            {
              "type": "urlParam",
              "name": "utm_campaign",
              "key": "campaignId"
            },
            {
              "type": "cookie",
              "name": "marketing_consent",
              "key": "consentStatus"
            }
          ]
        }
      }
    ]
  }
]

Data Submission

When the form is submitted, hidden fields are automatically included in the payload sent to the CoreForms backend alongside the visible form field data. If a hidden field fails to resolve (e.g., no referrer available), a warning is logged to the console and the field is skipped.

Example Configuration

The snippet below shows an example pages.jsonc configuration of the backend and turnstile plugin in combination with a dynamic form with one input field.

  "libraries": [
    {
      "package": "@allianz/taly-core-forms",
      "version": ""
    }
  ],
  "plugins": [
    {
      "package": "@allianz/taly-core-forms",
      "modules": [
        {
          "name": "BackendIntegrationPluginModule",
          "options": {
            "formId": "123456789",
            "formName": "my-form",
            "tenant": "sandbox",
            "site": "coreforms",
            "instance": "emeaprd",
            "talyTurnstileFormfieldId": "turnstile-token",
            "talyPrefix": "core-forms-"
          }
        }
      ]
    },
    {
      "package": "@allianz/taly-core-forms",
      "modules": [
        {
          "name": "TurnstileComponentPluginModule"
        }
      ]
    }
  ],
  "pages": [
    {
      "id": "first-page",
      "blocks": [
        {
          "id": "core-forms-my-dynamic-form1",
          "form": {
            "layout": {
              "type": "ONE_COLUMN"
            },
            "fields": [
              {
                "id": "street",
                "type": "INPUT",
                "label": "street",
                "inputType": "text"
              },
              {
                "type": "CUSTOM_COMPONENT",
                "name": "TurnstileComponent",
                "id": "turnstile-token",
                "config": {
                  "sitekey": "1x00000000000000000000AA"
                }
              }
            ]
          }
        }
      ],
    }
  ]

import {
  PfeActionsService,
  PfeBusinessService,
  PfeServiceActivatorService
} from '@allianz/ngx-pfe';
import { BFF_BASE_URL_TOKEN } from '@allianz/taly-core';
import { BuildingBlockConfiguration, DynamicFormBBConfiguration } from '@allianz/taly-core/schemas';
import { PfeRuntimeConfigService } from '@allianz/taly-pfe-connector';
import { HttpClient, HttpParams } from '@angular/common/http';
import { effect, inject, Injectable, Signal } from '@angular/core';
import {
  CORE_FORMS_BACKEND_INTEGRATION_PLUGIN_OPTIONS,
  CoreFormsBackendIntegrationPluginOptions,
  HiddenFieldConfig,
  MappedHiddenField,
  AutoHiddenField
} from './backend-integration-plugin.module';
import { PfeTrackingService } from '@allianz/ngx-pfe/tracking';

@Injectable()
export class BackendIntegrationPlugin {
  private readonly pfeActionService = inject(PfeActionsService);
  private readonly pfeBusinessService = inject(PfeBusinessService);
  private readonly runtimeConfigService = inject(PfeRuntimeConfigService);
  private readonly pfeServiceActivatorService = inject(PfeServiceActivatorService);
  private readonly pfeTrackingService = inject(PfeTrackingService);
  private readonly http = inject(HttpClient);
  private options = inject<Signal<CoreFormsBackendIntegrationPluginOptions>>(
    CORE_FORMS_BACKEND_INTEGRATION_PLUGIN_OPTIONS
  );
  private readonly requiredStringProperties: Array<keyof CoreFormsBackendIntegrationPluginOptions> =
    ['formId', 'tenant', 'site', 'instance', 'talyTurnstileFormfieldId', 'talyPrefix'];
  private readonly coreFormsBaseUrl = inject(BFF_BASE_URL_TOKEN);

  private readonly defaultBlocklist: (string | RegExp)[] = [
    /.*(password|pwd).*/i, // Any password variant (password, pwd, user_password, etc.)
    /.*secret.*/i, // Any secret (secret, client_secret, api_secret, etc.)
    /.*token.*/i, // Any token (token, auth_token, access_token, etc.)
    /.*(csrf|xsrf).*/i, // CSRF/XSRF tokens (csrf, xsrf, csrf_token, etc.)
    /.*session.*/i, // Session identifiers
    /.*auth.*/i, // Authentication related
    /.*jwt.*/i, // JSON Web Tokens
    /.*bearer.*/i, // Bearer tokens
    /.*(api|private).*key.*/i // API/private keys (apiKey, privateKey, myApiKey, etc.)
  ];

  constructor() {
    effect(async () => {
      const isCoreFormsBackendIntegrationPluginOptions = (
        obj: unknown
      ): obj is CoreFormsBackendIntegrationPluginOptions => {
        if (!obj || typeof obj !== 'object') {
          return false;
        }
        const options = obj as Record<string, unknown>;
        return this.requiredStringProperties.every(
          (prop) => prop in options && typeof options[prop] === 'string'
        );
      };

      if (!this.options() || !Object.keys(this.options()).length) {
        return;
      }

      if (isCoreFormsBackendIntegrationPluginOptions(this.options())) {
        await this.fetchTurnstileSitekey();
      } else {
        console.error(
          `Invalid options format for BackendIntegrationPlugin. Please provide the following plugin options: ${this.requiredStringProperties.join(
            ', '
          )}.`
        );
      }
    });

    effect(() => {
      this.pfeTrackingService.pushEvent(
        'formInitialized',
        { ...this.options(), fields: this.getRelevantFormFields() },
        true
      );
    });
  }

  registerActions() {
    this.pfeActionService.registerAction(
      'CORE_FORMS_BACKEND_INTEGRATION',
      this.sendFormDataCoreFormsBackend.bind(this)
    );
  }

  private async sendFormDataCoreFormsBackend(): Promise<void> {
    this.pfeTrackingService.pushEvent('formSubmitClick', true, true);

    const fields = this.getCoreFormsState(this.options().talyPrefix);
    const visibilityHash = await this.computeVisibilityHash();

    const hiddenFields = this.options().hiddenFields;
    if (hiddenFields?.length) {
      const hiddenFieldData = this.getHiddenFields(hiddenFields);

      this.warnOnOverlappingKeys(hiddenFieldData, fields);

      Object.assign(fields, hiddenFieldData);
    }

    const turnstileToken = fields?.[this.options().talyTurnstileFormfieldId];

    if (!turnstileToken || typeof turnstileToken !== 'string') {
      this.pfeTrackingService.pushEvent(
        'formSubmitError',
        { message: 'Could not find the turnstile token in the form' },
        true
      );
      throw new Error('Could not find the turnstile token in the form');
    }

    delete fields[this.options().talyTurnstileFormfieldId];

    return this.postFormData(turnstileToken, fields, visibilityHash);
  }

  private postFormData(
    turnstileToken: string,
    fieldsForBackend: Record<string, unknown>,
    visibilityHash: string
  ): Promise<void> {
    const { formId, tenant, site, instance } = this.options();
    const referrer = window.location.href;
    const data = JSON.stringify({
      version: 1,
      formId,
      tenant,
      site,
      visibilityHash,
      instance,
      referrer,
      fields: fieldsForBackend,
      turnstileCaptcha: {
        cfturnstileresponse: turnstileToken
      },
      captchaType: 'turnstile'
    });
    this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(true);

    return new Promise<void>((resolve) => {
      this.http.post<{ message: string }>(this.coreFormsBaseUrl(), data).subscribe({
        next: (response) => {
          this.pfeTrackingService.pushEvent('formSubmitSuccess', true, true);
          this.pfeBusinessService.storeValueByExpression(
            '$.coreFormSubmissionSuccessful',
            response.message === 'successful'
          );
          this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(false);
          resolve();
        },
        error: (error) => {
          this.pfeTrackingService.pushEvent(
            'formSubmitError',
            {
              status: error?.status,
              message: error?.message
            },
            true
          );
          this.pfeBusinessService.storeValueByExpression('$.coreFormSubmissionSuccessful', false);
          this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(false);
          resolve();
        }
      });
    });
  }

  private getCoreFormsState(prefix: string): Record<string, unknown> {
    const pfeState = this.pfeBusinessService.getFullState();

    return Object.entries(pfeState)
      .filter(([formKey]) => formKey.startsWith(prefix)) // Filter keys that start with the prefix
      .filter(([, formfields]) => typeof formfields === 'object' && formfields !== null)
      .reduce<Record<string, unknown>>((relevantFields, [, formfields]) => {
        Object.entries(formfields).forEach(([formfieldKey, formfieldValue]) => {
          if (relevantFields[formfieldKey]) {
            console.warn(
              `The form field with id "${formfieldKey}" is present in more than one form with prefix "${prefix}". Please provide a unique id for each field.`
            );
            return;
          }
          if (formfieldValue !== null) {
            relevantFields[formfieldKey] = formfieldValue;
          }
        });
        return relevantFields;
      }, {});
  }

  private fetchTurnstileSitekey(): Promise<void> {
    if (!this.coreFormsBaseUrl()) {
      return Promise.reject(
        'Base URL is not defined. Please forward `{"bffBaseUrl": ""}` via `runtime-app-config` input'
      );
    }

    const requestUrl = [
      this.coreFormsBaseUrl(),
      this.options().instance,
      this.options().tenant,
      this.options().site,
      this.options().formId
    ].join('/');

    const referrer = window.location.href;
    const relevantFormFieldIds = this.getRelevantFormFields().map((formfield) => formfield.id);
    const params = new HttpParams()
      .set('referrer', referrer)
      .set('fields', relevantFormFieldIds.join(','));
    this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(true);

    return new Promise<void>((resolve) => {
      this.http.head(requestUrl, { params, observe: 'response' }).subscribe({
        next: (response) => {
          const sitekey = response.headers.get('x-cf-turnstile-sitekey');
          this.pfeBusinessService.storeValueByExpression('$._taly.turnstileSitekey', sitekey);
          this.pfeBusinessService.storeValueByExpression('$._taly.turnstileSitekeyError', null);
          this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(false);
          resolve();
        },
        error: (error) => {
          this.pfeTrackingService.pushEvent(
            'formLoadError',
            {
              status: error.status,
              message: error.message
            },
            true
          );
          this.pfeBusinessService.storeValueByExpression('$._taly.turnstileSitekey', '');
          this.pfeBusinessService.storeValueByExpression('$._taly.turnstileSitekeyError', error);
          console.error(error);
          this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(false);
          resolve();
        }
      });
    });
  }

  private getRelevantFormFields() {
    const { pages } = this.runtimeConfigService.pagesConfig();
    const { talyTurnstileFormfieldId, talyPrefix } = this.options();

    function isDynamicFormBbConfig(
      block: DynamicFormBBConfiguration | BuildingBlockConfiguration | undefined
    ): block is DynamicFormBBConfiguration {
      if (!block) {
        return false;
      }
      return Boolean((block as DynamicFormBBConfiguration).form);
    }

    const formFields = pages
      .flatMap((page) => page?.blocks)
      .filter(isDynamicFormBbConfig)
      .filter((block) => block?.id.startsWith(talyPrefix))
      .flatMap((block) => block?.form?.fields);

    return formFields
      .filter((formfield) => formfield?.id !== talyTurnstileFormfieldId)
      .filter(
        (formfield) =>
          formfield.type !== 'LINE_BREAK' &&
          formfield.type !== 'HEADLINE' &&
          formfield.type !== 'PARAGRAPH'
      );
  }

  private getFormFieldIdsFromState(prefix: string): string[] {
    const pfeState = this.pfeBusinessService.getFullState();

    const fieldIds = Object.entries(pfeState)
      .filter(([formKey]) => formKey.startsWith(prefix))
      .filter(([, formfields]) => typeof formfields === 'object' && formfields !== null)
      .flatMap(([, formfields]) => Object.keys(formfields));

    return [...new Set(fieldIds)];
  }

  private async computeVisibilityHash(): Promise<string> {
    const { talyTurnstileFormfieldId, talyPrefix } = this.options();

    const fieldIds = this.getFormFieldIdsFromState(talyPrefix).filter(
      (fieldId) => fieldId !== talyTurnstileFormfieldId
    );

    const sortedFieldIds = fieldIds.sort();
    const serializedFieldIds = JSON.stringify(sortedFieldIds);

    // Compute SHA-256 hash
    const textEncoder = new TextEncoder();
    const encodedData = textEncoder.encode(serializedFieldIds);
    const hashBuffer = await crypto.subtle.digest('SHA-256', encodedData);

    // Convert to hex string
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('');
    const truncatedHash = hashHex.substring(0, 12);

    return truncatedHash;
  }

  private getHiddenFields(configs: HiddenFieldConfig[]): Record<string, string> {
    const hiddenFieldData: Record<string, string> = {};

    for (const config of configs) {
      try {
        let newHiddenField: Record<string, string> = {};

        switch (config.type) {
          case 'cookie':
          case 'urlParam':
            newHiddenField = this.getMappedField(config);
            break;
          case 'static':
            newHiddenField[config.key] = config.value;
            break;
          case 'referrer':
          case 'userAgent':
            newHiddenField = this.getAutoField(config);
            break;
        }

        this.warnOnOverlappingKeys(newHiddenField, hiddenFieldData);

        Object.assign(hiddenFieldData, newHiddenField);
      } catch (error) {
        console.warn(`Failed to resolve hidden field :`, error);
      }
    }

    return hiddenFieldData;
  }

  private getMappedField(field: MappedHiddenField): Record<string, string> {
    const hiddenFieldData: Record<string, string> = {};

    const blocklist = this.getBlocklist();
    const isBlocked = this.isParameterBlocked(field.name, blocklist);

    const key = field.key || field.name;
    let sourceValue: string;

    if (isBlocked) {
      const sourceType = field.type === 'cookie' ? 'cookie' : 'URL parameter';
      console.error(
        `BLOCKED: ${sourceType} "${field.name}" matches blocklist pattern. ` +
          `This field will be skipped to prevent potential security or privacy issues.`
      );
      return hiddenFieldData;
    }

    if (field.type === 'cookie') {
      sourceValue = this.getCookieValue(field.name);
    } else {
      const urlParams = new URLSearchParams(window.location.search);
      sourceValue = urlParams.get(field.name) ?? '';
    }

    hiddenFieldData[key] = sourceValue;
    return hiddenFieldData;
  }

  private getBlocklist(): (string | RegExp)[] {
    const userBlocklist = this.options().blocklist ?? [];
    // Merge user blocklist with defaults for added security
    return [...this.defaultBlocklist, ...userBlocklist];
  }

  private isParameterBlocked(paramName: string, blocklist: (string | RegExp)[]): boolean {
    return blocklist.some((pattern) => {
      if (typeof pattern === 'string') {
        return paramName === pattern;
      } else {
        return pattern.test(paramName);
      }
    });
  }

  private getAutoField(field: AutoHiddenField): Record<string, string> {
    const hiddenFieldData: Record<string, string> = {};
    const key = field.key || field.type;
    const fieldType = field.type;
    let value = '';

    if (fieldType === 'referrer') {
      value = this.getDocumentReferrer();
    } else {
      value = this.getBrowserUserAgent();
    }

    hiddenFieldData[key] = value;

    return hiddenFieldData;
  }

  private getDocumentReferrer(): string {
    return document?.referrer ?? '';
  }

  private getBrowserUserAgent(): string {
    return navigator?.userAgent ?? '';
  }

  private warnOnOverlappingKeys(
    newData: Record<string, string>,
    existingData: Record<string, unknown>
  ): void {
    const overlappingKeys = Object.keys(newData).filter((key) => key in existingData);
    if (overlappingKeys.length > 0) {
      console.warn(
        `Overlapping keys detected: ${overlappingKeys.join(', ')}. This may result in data loss.`
      );
    }
  }

  private getCookieValue(name: string): string {
    if (!document?.cookie) {
      return '';
    }

    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
      const [key, ...valueParts] = cookie.trim().split('=');
      if (key === name && valueParts.length > 0) {
        const rawValue = valueParts.join('=');
        try {
          return decodeURIComponent(rawValue);
        } catch (error) {
          console.warn(`Failed to decode cookie value for "${name}", returning raw value:`, error);
          return rawValue;
        }
      }
    }
    return '';
  }
}

results matching ""

    No results matching ""