Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ export {default as extractEmojiFromUri} from './utils/extractEmojiFromUri';
export {default as set} from './utils/set';
export {default as get} from './utils/get';
export {default as startCase} from './utils/startCase';
export {
default as substituteTranslationParams,
hasUnresolvedTranslationParams,
} from './utils/substituteTranslationParams';
export {default as removeTrailingSlash} from './utils/removeTrailingSlash';
export {default as resolveFieldName} from './utils/resolveFieldName';
export {default as resolveResourceEndpoint} from './utils/resolveResourceEndpoint';
Expand Down
5 changes: 4 additions & 1 deletion packages/javascript/src/models/embedded-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ export interface EmbeddedFlowExecuteRequestConfigBase<T = any> extends Partial<R
/**
* Internationalized message structure returned by the backend.
*
* The `defaultValue` field carries the untranslated fallback text.
* The `defaultValue` field carries the untranslated fallback text, already substituted with
* `params`. When resolving `key` against a translation bundle instead, `params` must be applied
* to the resolved value, since bundle entries keep their `{{param(name)}}` placeholders.
*/
export interface I18nMessage {
defaultValue?: string;
key: string;
params?: Record<string, string>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {describe, it, expect} from 'vitest';
import substituteTranslationParams, {hasUnresolvedTranslationParams} from '../substituteTranslationParams';

describe('substituteTranslationParams', () => {
it('substitutes the backend `{{param(name)}}` syntax', () => {
expect(
substituteTranslationParams('User already exists with the provided {{param(attribute)}}', {attribute: 'email'}),
).toBe('User already exists with the provided email');
});

it('tolerates whitespace inside the backend placeholder', () => {
expect(substituteTranslationParams('The provided {{ param( attribute ) }} is taken', {attribute: 'username'})).toBe(
'The provided username is taken',
);
});

it('substitutes the bundle `{name}` syntax', () => {
expect(substituteTranslationParams('Minimum length is {min} characters', {min: 8})).toBe(
'Minimum length is 8 characters',
);
});

it('substitutes every occurrence of every param', () => {
expect(
substituteTranslationParams('{{param(attribute)}} and {other} conflict with {{param(attribute)}}', {
attribute: 'email',
other: 'username',
}),
).toBe('email and username conflict with email');
});

it('leaves placeholders without a matching param untouched', () => {
expect(substituteTranslationParams('The provided {{param(attribute)}} is taken', {unrelated: 'x'})).toBe(
'The provided {{param(attribute)}} is taken',
);
});

it('returns the translation unchanged when no params are given', () => {
expect(substituteTranslationParams('The provided {{param(attribute)}} is taken')).toBe(
'The provided {{param(attribute)}} is taken',
);
expect(substituteTranslationParams('Plain message', {})).toBe('Plain message');
expect(substituteTranslationParams('', {attribute: 'email'})).toBe('');
});

it('treats param values as literals rather than replacement patterns', () => {
expect(substituteTranslationParams('Value: {{param(attribute)}}', {attribute: '$&'})).toBe('Value: $&');
});
});

describe('hasUnresolvedTranslationParams', () => {
it('detects a remaining backend placeholder', () => {
expect(hasUnresolvedTranslationParams('The provided {{param(attribute)}} is taken')).toBe(true);
expect(hasUnresolvedTranslationParams('The provided {{ param( attribute ) }} is taken')).toBe(true);
});

it('reports fully resolved strings as resolved', () => {
expect(hasUnresolvedTranslationParams('The provided email is taken')).toBe(false);
expect(hasUnresolvedTranslationParams('Minimum length is {min} characters')).toBe(false);
expect(hasUnresolvedTranslationParams('')).toBe(false);
});
});
55 changes: 55 additions & 0 deletions packages/javascript/src/utils/substituteTranslationParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

/**
* Matches the `{{param(name)}}` placeholder syntax used by backend messages.
*/
const BACKEND_PARAM_PATTERN = /\{\{\s*param\(\s*\w+\s*\)\s*\}\}/;

/**
* Escapes characters that carry special meaning inside a regular expression.
*
* @param value - The literal string to escape.
* @returns The escaped string, safe to embed in a `RegExp`.
*/
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

/**
* Checks whether a translation still contains an unsubstituted `{{param(name)}}` placeholder.
*
* Used to detect that a resolved translation is not presentable to the user, so the caller can
* fall back to a pre-substituted value instead.
*
* @param translation - The translation string to inspect.
* @returns `true` when at least one backend placeholder remains unsubstituted.
*/
export const hasUnresolvedTranslationParams = (translation: string): boolean =>
Boolean(translation) && BACKEND_PARAM_PATTERN.test(translation);

/**
* Substitutes named parameters into a translation string.
*
* Two placeholder syntaxes are supported, because messages reach the SDK from two sources:
* - `{{param(name)}}` is used by backend messages and the server-shipped `system` i18n bundle.
* - `{name}` is used by the SDK's own translation bundles.
*
* @param translation - The translation string, possibly containing placeholders.
* @param params - The parameter values to substitute, keyed by placeholder name.
* @returns The translation with every matching placeholder replaced.
*/
const substituteTranslationParams = (translation: string, params?: Record<string, string | number>): string => {
if (!translation || !params || Object.keys(params).length === 0) {
return translation;
}

return Object.entries(params).reduce((acc: string, [paramKey, paramValue]: [string, string | number]): string => {
const escapedKey: string = escapeRegExp(paramKey);
const value = String(paramValue);

return acc
.replace(new RegExp(`\\{\\{\\s*param\\(\\s*${escapedKey}\\s*\\)\\s*\\}\\}`, 'g'), () => value)
.replace(new RegExp(`\\{${escapedKey}\\}`, 'g'), () => value);
}, translation);
};

export default substituteTranslationParams;
11 changes: 2 additions & 9 deletions packages/react/src/contexts/I18n/I18nProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getDefaultI18nBundles,
normalizeTranslations,
getVendorPrefix,
substituteTranslationParams,
} from '@thunderid/browser';
import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState} from 'react';
import I18nContext, {I18nContextValue} from './I18nContext';
Expand Down Expand Up @@ -273,15 +274,7 @@ const I18nProvider: FC<PropsWithChildren<I18nProviderProps>> = ({
}

// Replace parameters if provided
if (params && Object.keys(params).length > 0) {
return Object.entries(params).reduce(
(acc: string, [paramKey, paramValue]: [string, string | number]) =>
acc.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)),
translation,
);
}

return translation;
return substituteTranslationParams(translation, params);
},
[mergedBundles, currentLanguage, fallbackLanguage],
);
Expand Down
11 changes: 2 additions & 9 deletions packages/react/src/hooks/useTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
I18nBundle,
I18nTranslations,
normalizeTranslations,
substituteTranslationParams,
} from '@thunderid/browser';
import {useContext, useMemo} from 'react';
import ComponentPreferencesContext from '../contexts/I18n/ComponentPreferencesContext';
Expand Down Expand Up @@ -132,15 +133,7 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW
}

// Replace parameters if provided
if (params && Object.keys(params).length > 0) {
return Object.entries(params).reduce(
(acc: string, [paramKey, paramValue]: [string, string | number]) =>
acc.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)),
translation,
);
}

return translation;
return substituteTranslationParams(translation, params);
};
}, [mergedBundles, currentLanguage, fallbackLanguage, globalT, effectivePreferences?.bundles]);

Expand Down
103 changes: 103 additions & 0 deletions packages/react/src/utils/__tests__/flowTransformer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {describe, it, expect} from 'vitest';
import {extractErrorMessage} from '../flowTransformer';

const UNIQUENESS_KEY = 'flows.executor.errors.attribute_not_unique';

/**
* Builds a `t` stub backed by a flat bundle, mirroring how `I18nProvider` resolves keys:
* a miss returns the key itself, and params are substituted into the resolved value.
*/
const createTranslator =
(bundle: Record<string, string> = {}) =>
(key: string, params?: Record<string, string | number>): string => {
const translation: string = bundle[key] ?? key;

if (!params) {
return translation;
}

return Object.entries(params).reduce(
(acc: string, [paramKey, paramValue]: [string, string | number]): string =>
acc
.replace(new RegExp(`\\{\\{\\s*param\\(\\s*${paramKey}\\s*\\)\\s*\\}\\}`, 'g'), String(paramValue))
.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)),
translation,
);
};
Comment on lines +4 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify that the shared helper is exported and find the duplicate test fixtures.
rg -n -C 3 'substituteTranslationParams|const createTranslator' packages

Repository: thunder-id/javascript-sdks

Length of output: 13090


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- shared helper ---'
cat -n packages/javascript/src/utils/substituteTranslationParams.ts

printf '%s\n' '--- browser exports ---'
rg -n -C 3 'substituteTranslationParams' packages/browser/src packages/javascript/src/index.ts

printf '%s\n' '--- React fixture ---'
cat -n packages/react/src/utils/__tests__/flowTransformer.test.ts | sed -n '1,35p'

printf '%s\n' '--- Vue fixture ---'
cat -n packages/vue/src/utils/__tests__/flowTransformer.test.ts | sed -n '1,35p'

printf '%s\n' '--- package dependency declarations ---'
rg -n -C 2 '"`@thunderid/`(javascript|browser)"' packages/react/package.json packages/vue/package.json

Repository: thunder-id/javascript-sdks

Length of output: 7403


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re

def local_substitute(translation, params):
    for param_key, param_value in params.items():
        translation = re.sub(
            r'\{\{\s*param\(\s*' + param_key + r'\s*\)\s*\}\}',
            lambda m: str(param_value),
            translation,
        )
        translation = re.sub(
            r'\{' + param_key + r'\}',
            lambda m: str(param_value),
            translation,
        )
    return translation

def shared_substitute(translation, params):
    if not translation or not params:
        return translation
    for param_key, param_value in params.items():
        escaped = re.escape(param_key)
        translation = re.sub(
            r'\{\{\s*param\(\s*' + escaped + r'\s*\)\s*\}\}',
            lambda m: str(param_value),
            translation,
        )
        translation = re.sub(
            r'\{' + escaped + r'\}',
            lambda m: str(param_value),
            translation,
        )
    return translation

cases = [
    ("Value: {{param(attribute)}}", {"attribute": "$&"}),
    ("Value: {a.b}", {"a.b": "ok"}),
    ("Value: {a+b}", {"a+b": "ok"}),
]
for translation, params in cases:
    print(repr((translation, params)))
    print("local :", repr(local_substitute(translation, params)))
    print("shared:", repr(shared_substitute(translation, params)))
PY

Repository: thunder-id/javascript-sdks

Length of output: 404


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- browser package exports ---'
fd -i 'index.ts' packages/browser packages/javascript | sort | xargs -r rg -n -C 4 'substituteTranslationParams|`@thunderid/javascript`'

printf '%s\n' '--- JavaScript replacement semantics ---'
node - <<'JS'
function localSubstitute(translation, params) {
  return Object.entries(params).reduce(
    (acc, [paramKey, paramValue]) =>
      acc
        .replace(new RegExp(`\\{\\{\\s*param\\(\\s*${paramKey}\\s*\\)\\s*\\}\\}`, 'g'), String(paramValue))
        .replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)),
    translation,
  );
}

function escapeRegExp(value) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function sharedSubstitute(translation, params) {
  return Object.entries(params).reduce((acc, [paramKey, paramValue]) => {
    const escapedKey = escapeRegExp(paramKey);
    const value = String(paramValue);
    return acc
      .replace(new RegExp(`\\{\\{\\s*param\\(\\s*${escapedKey}\\s*\\)\\s*\\}\\}`, 'g'), () => value)
      .replace(new RegExp(`\\{${escapedKey}\\}`, 'g'), () => value);
  }, translation);
}

for (const [translation, params] of [
  ['Value: {{param(attribute)}}', {attribute: '$&'}],
  ['Value: {{param(a+b)}}', {'a+b': 'ok'}],
  ['Value: {a.b}', {'a.b': 'ok'}],
]) {
  console.log(JSON.stringify({translation, params}));
  console.log('local :', JSON.stringify(localSubstitute(translation, params)));
  console.log('shared:', JSON.stringify(sharedSubstitute(translation, params)));
}
JS

Repository: thunder-id/javascript-sdks

Length of output: 1988


Use substituteTranslationParams from @thunderid/browser in both test translators.

The local implementations do not escape parameter names and pass replacement values as strings. Values such as a+b or $& therefore differ from provider behavior in both files.

🧰 Tools
🪛 ast-grep (0.45.1)

[warning] 24-24: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(\\{\\{\\s*param\\(\\s*${paramKey}\\s*\\)\\s*\\}\\}, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity

(regexp-from-variable)


[warning] 25-25: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(\\{${paramKey}\\}, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity

(regexp-from-variable)

📍 Affects 2 files
  • packages/react/src/utils/__tests__/flowTransformer.test.ts#L4-L29 (this comment)
  • packages/vue/src/utils/__tests__/flowTransformer.test.ts#L4-L29
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/src/utils/__tests__/flowTransformer.test.ts` around lines 4 -
29, Replace the local createTranslator parameter-substitution implementations in
packages/react/src/utils/__tests__/flowTransformer.test.ts (lines 4-29) and
packages/vue/src/utils/__tests__/flowTransformer.test.ts (lines 4-29) with
substituteTranslationParams from `@thunderid/browser`. Preserve each test
translator’s existing bundle lookup and missing-key behavior while delegating
parameter substitution to the shared helper in both files.

Source: Coding guidelines


/**
* The attribute-uniqueness failure as the backend sends it: an INCOMPLETE step whose `error`
* carries the offending attribute in `params`, with `defaultValue` already substituted.
*/
const uniquenessResponse = (attribute: string) => ({
error: {
code: 'FET-1061',
description: {
defaultValue: `The provided ${attribute} is already associated with another user and expects a unique value`,
key: `${UNIQUENESS_KEY}_desc`,
params: {attribute},
},
message: {
defaultValue: `User already exists with the provided ${attribute}`,
key: UNIQUENESS_KEY,
params: {attribute},
},
},
executionId: 'exec-1',
flowStatus: 'INCOMPLETE',
});

describe('extractErrorMessage', () => {
it('substitutes the attribute name into the bundle translation', () => {
const t = createTranslator({
[`system.${UNIQUENESS_KEY}`]: 'User already exists with the provided {{param(attribute)}}',
});

expect(extractErrorMessage(uniquenessResponse('email'), t)).toBe('User already exists with the provided email');
expect(extractErrorMessage(uniquenessResponse('username'), t)).toBe(
'User already exists with the provided username',
);
});

it('substitutes params resolved from the unprefixed key too', () => {
const t = createTranslator({[UNIQUENESS_KEY]: 'The {{param(attribute)}} you entered is taken'});

expect(extractErrorMessage(uniquenessResponse('email'), t)).toBe('The email you entered is taken');
});

it('falls back to defaultValue when the bundle template keeps an unresolved placeholder', () => {
const t = createTranslator({
[`system.${UNIQUENESS_KEY}`]: 'User already exists with the provided {{param(attribute)}}',
});
const response = uniquenessResponse('email');
delete (response.error.message as {params?: Record<string, string>}).params;

expect(extractErrorMessage(response, t)).toBe('User already exists with the provided email');
});

it('falls back to defaultValue when the key is not in any bundle', () => {
expect(extractErrorMessage(uniquenessResponse('email'), createTranslator())).toBe(
'User already exists with the provided email',
);
});

it('falls back to the description defaultValue when the message has none', () => {
const response = uniquenessResponse('email');
delete (response.error.message as {defaultValue?: string}).defaultValue;

expect(extractErrorMessage(response, createTranslator())).toBe(
'The provided email is already associated with another user and expects a unique value',
);
});

it('still supports the legacy failureReason, Error instances and the generic fallback', () => {
const t = createTranslator({'errors.flow.generic': 'Something went wrong'});

expect(extractErrorMessage({failureReason: 'Invalid credentials'}, t)).toBe('Invalid credentials');
expect(extractErrorMessage(new Error('Network down'), t)).toBe('Network down');
expect(extractErrorMessage(undefined, t)).toBe('Something went wrong');
});
});
27 changes: 18 additions & 9 deletions packages/react/src/utils/flowTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@
* consistent response handling across all embedded flows.
*/

import {EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser';
import {
EmbeddedFlowComponent,
FlowMetadataResponse,
I18nMessage,
hasUnresolvedTranslationParams,
} from '@thunderid/browser';
import resolveTranslationsInArray from './resolveTranslationsInArray';
import {UseTranslation} from '../hooks/useTranslation';

Expand All @@ -37,8 +42,8 @@ import {UseTranslation} from '../hooks/useTranslation';
export interface FlowErrorResponse {
error?: {
code: string;
description: {key: string; defaultValue?: string};
message: {key: string; defaultValue?: string};
description: I18nMessage;
message: I18nMessage;
};
executionId: string;
flowStatus: 'ERROR';
Expand Down Expand Up @@ -222,8 +227,10 @@ export const transformComponents = (
* Extract error message from flow error response.
*
* Resolution order:
* 1. Structured `error` object: try i18n lookup via `t(error.message.key)`.
* 2. Fallback to `defaultValue` from `message`, then `description`.
* 1. Structured `error` object: try i18n lookup via `t(error.message.key, error.message.params)`.
* Bundle entries keep the backend's `{{param(name)}}` placeholders, so `params` must be passed
* through; a resolved value that still holds an unsubstituted placeholder is treated as a miss.
* 2. Fallback to `defaultValue` from `message`, then `description` (the backend pre-substitutes it).
* 3. Standard `Error.message`.
* 4. Generic translated fallback via `defaultErrorKey`.
*/
Expand All @@ -237,15 +244,17 @@ export const extractErrorMessage = (

// 1. Try i18n lookup on message.key first (preferred for user-facing errors)
if (flowError?.message?.key) {
const translated: string = t(flowError.message.key);
if (translated && translated !== flowError.message.key) {
const params: Record<string, string> | undefined = flowError.message.params;

const translated: string = t(flowError.message.key, params);
if (translated && translated !== flowError.message.key && !hasUnresolvedTranslationParams(translated)) {
return translated;
}

// If the key resolved to itself, retry under the 'system:' namespace
const systemKey = `system.${flowError.message.key}`;
const systemTranslated: string = t(systemKey);
if (systemTranslated && systemTranslated !== systemKey) {
const systemTranslated: string = t(systemKey, params);
if (systemTranslated && systemTranslated !== systemKey && !hasUnresolvedTranslationParams(systemTranslated)) {
return systemTranslated;
}
}
Expand Down
11 changes: 2 additions & 9 deletions packages/vue/src/providers/I18nProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getDefaultI18nBundles,
normalizeTranslations,
getVendorPrefix,
substituteTranslationParams,
} from '@thunderid/browser';
import {
computed,
Expand Down Expand Up @@ -278,15 +279,7 @@ const I18nProvider: Component = defineComponent({
translation = key;
}

if (params && Object.keys(params).length > 0) {
return Object.entries(params).reduce(
(acc: string, [paramKey, paramValue]: [key: string, value: string | number]): string =>
acc.replaceAll(`{${paramKey}}`, String(paramValue)),
translation,
);
}

return translation;
return substituteTranslationParams(translation, params);
};

const setLanguage = (language: string): void => {
Expand Down
Loading
Loading