libs/core-forms/src/lib/backend-integration-plugin/backend-integration-plugin.ts
Methods |
constructor()
|
| registerActions |
registerActions()
|
|
Returns :
void
|
⚠️ This plugin is for internal usage only!
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"
}
]
}
]@allianz/taly-core-forms package to the libraries array of the pages.jsonc file"libraries": [
{
"package": "@allianz/taly-core-forms",
"version": "CURRENT_VERSION"
}
]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-"
}
}
]
}
],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.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.
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 fieldskey: Custom key name for the hidden fieldvalue: Static value to includeThe plugin automatically captures browser and context information:
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"}.
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..."}.
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 defaultExample: 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"}.
cookie)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 capturekey: (Optional) The key name to use when sending this field to the backend. If not provided, the name will be used by defaultExample: If cookie contains "marketing_consent=granted; session=abc123", this configuration would capture only marketing_consent and send it as {"consentStatus": "granted"}.
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 variantssecret - Any secret (client_secret, api_secret, secretKey, etc.)token - Any token (auth_token, access_token, session_token, etc.)csrf, xsrf - CSRF/XSRF tokenssession - Session identifiersauth - Authentication related parameters (including authKey)jwt - JSON Web Tokensbearer - Bearer tokensapiKey, api_key, privateKey, private_key - API and private keysAll other parameters are allowed by default.
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:
"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"
}
]
}
}
]
}
]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.
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 '';
}
}