-
Notifications
You must be signed in to change notification settings - Fork 21
Populate attribute name in flow error messages #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
brionmario
merged 1 commit into
thunder-id:main
from
NipuniBhagya:attribute-placeholder-fix
Aug 11, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
65 changes: 65 additions & 0 deletions
65
packages/javascript/src/utils/__tests__/substituteTranslationParams.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
55
packages/javascript/src/utils/substituteTranslationParams.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
packages/react/src/utils/__tests__/flowTransformer.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ); | ||
| }; | ||
|
|
||
| /** | ||
| * 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'); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
Repository: thunder-id/javascript-sdks
Length of output: 13090
🏁 Script executed:
Repository: thunder-id/javascript-sdks
Length of output: 7403
🏁 Script executed:
Repository: thunder-id/javascript-sdks
Length of output: 404
🏁 Script executed:
Repository: thunder-id/javascript-sdks
Length of output: 1988
Use
substituteTranslationParamsfrom@thunderid/browserin both test translators.The local implementations do not escape parameter names and pass replacement values as strings. Values such as
a+bor$&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
Source: Coding guidelines