@@ -1141,15 +1141,22 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
11411141
11421142 <script>
11431143 // Template version — single source of truth for the SemVer constant.
1144- // Must match `TEMPLATE_VERSION = 'v21.0.3 '` in map2-auto-tagger-optimized.yaml (sync-check enforces this).
1145- const TEMPLATE_VERSION = 'v21.0.3 ';
1144+ // Must match `TEMPLATE_VERSION = 'v21.0.4 '` in map2-auto-tagger-optimized.yaml (sync-check enforces this).
1145+ const TEMPLATE_VERSION = 'v21.0.4 ';
11461146
11471147 // Version history surfaced in the Update flow. Bullets are intentionally English-only —
11481148 // translating release notes across 7 languages for every PR is unsustainable. Labels
11491149 // (titles, buttons) go through i18n; change bullets stay in source form.
11501150 // Tags: bugfix, coverage, breaking, security, perf, other.
11511151 // sync-check.py enforces that the newest entry's version matches TEMPLATE_VERSION.
11521152 const VERSION_HISTORY = [
1153+ {
1154+ version: 'v21.0.4',
1155+ date: '2026-04-28',
1156+ changes: [
1157+ { tag: 'bugfix', text: 'Configurator validation: agreement dates now validated as real calendar dates (F034). MPE ID length check added — 1-44 chars (F031). update.sh detects single-account stacks and provides SSM instructions (F026). update.sh scope edit validated for JSON correctness (F017). deploy.sh warns before updating existing stacks (F041).' },
1158+ ],
1159+ },
11531160 {
11541161 version: 'v21.0.3',
11551162 date: '2026-04-28',
@@ -1332,7 +1339,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
13321339 changes: [
13331340 { tag: 'bugfix', text: 'Editor-mode update.sh generator: every aws CLI call now has explicit --region "$REGION". Customers running the script with AWS_DEFAULT_REGION unset or set to a different region no longer deploy into the wrong region or fail on cross-region StackSet lookup.' },
13341341 { tag: 'bugfix', text: 'Editor-mode update.sh now reads the current template via describe-stack-set --query StackSet.TemplateBody instead of downloading the deprecated S3 staging copy (auto-map-tagger-<acct>/map-auto-tagger-accounts-<mpe>.yaml). The staging object was only created during the initial multi-account deploy and could be missing / stale / garbage-collected — this eliminates the S3 dependency entirely.' },
1335- { tag: 'bugfix', text: 'Upgrade-mode compare_versions now strict-validates three-part numeric SemVer (vMAJOR.MINOR.PATCH) and returns "error" on unparseable input instead of silently falling through to "patch". Prior behavior misclassified e.g. v21.0.3 -rc1 → v20.3.0 as patch; upgrade_one caller now fails closed and directs the operator to --force.' },
1342+ { tag: 'bugfix', text: 'Upgrade-mode compare_versions now strict-validates three-part numeric SemVer (vMAJOR.MINOR.PATCH) and returns "error" on unparseable input instead of silently falling through to "patch". Prior behavior misclassified e.g. v21.0.4 -rc1 → v20.3.0 as patch; upgrade_one caller now fails closed and directs the operator to --force.' },
13361343 ],
13371344 },
13381345 {
@@ -1575,7 +1582,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
15751582 if (action === 'add') rows.push([t('ui_backfill_title'), backfill ? t('rv_backfill_enabled') : t('rv_disabled')]);
15761583 // Safe DOM construction: v may contain user-controlled values
15771584 // (customer name, account IDs) so use textContent rather than
1578- // innerHTML template-literal interpolation. (§1.94, v21.0.3 )
1585+ // innerHTML template-literal interpolation. (§1.94, v21.0.4 )
15791586 table.replaceChildren();
15801587 rows.forEach(([k, v], i) => {
15811588 const tr = document.createElement('tr');
@@ -1682,7 +1689,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
16821689 ];
16831690 // Safe DOM construction: v may contain user-controlled values
16841691 // (MPE IDs) so use textContent rather than innerHTML template-
1685- // literal interpolation. (§1.94, v21.0.3 )
1692+ // literal interpolation. (§1.94, v21.0.4 )
16861693 table.replaceChildren();
16871694 rows.forEach(([k, v], i) => {
16881695 const tr = document.createElement('tr');
@@ -1831,7 +1838,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
18311838 // (MPE IDs, region strings) so use textContent rather than
18321839 // innerHTML template-literal interpolation. Preserves the
18331840 // delete-review styles (padding 10px 12px, v-cell font-weight
1834- // 600). (§1.94, v21.0.3 )
1841+ // 600). (§1.94, v21.0.4 )
18351842 const deleteTable = document.getElementById('delete-reviewTable');
18361843 deleteTable.replaceChildren();
18371844 rows.forEach(([k, v], i) => {
@@ -2221,7 +2228,17 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
22212228# ── Step 1: Verify StackSet exists ────────────
22222229echo "Step 1: Checking existing deployment..."
22232230if ! aws cloudformation describe-stack-set --region "$REGION" --stack-set-name "$STACKSET_NAME" > /dev/null 2>&1; then
2224- echo " ❌ StackSet '$STACKSET_NAME' not found in region $REGION."
2231+ SINGLE_STATUS=\$(aws cloudformation describe-stacks --region "$REGION" --stack-name "$STACKSET_NAME" --query 'Stacks[0].StackStatus' --output text 2>/dev/null || echo "NONE")
2232+ if [ "$SINGLE_STATUS" != "NONE" ] && [ "$SINGLE_STATUS" != "None" ]; then
2233+ echo " ⚠️ Single-account stack '$STACKSET_NAME' found (status: $SINGLE_STATUS)."
2234+ echo " This update tool targets multi-account StackSet deployments."
2235+ echo " For single-account scope changes, update SSM directly:"
2236+ echo " aws ssm get-parameter --name '/auto-map-tagger/$MPE/config' --region $REGION"
2237+ echo " # Edit the JSON, then:"
2238+ echo " aws ssm put-parameter --name '/auto-map-tagger/$MPE/config' --value '<new-json>' --type String --overwrite --region $REGION"
2239+ exit 1
2240+ fi
2241+ echo " ❌ No deployment '$STACKSET_NAME' found in region $REGION."
22252242 echo " Run the initial deploy.sh from the configurator first."
22262243 exit 1
22272244fi
@@ -2258,6 +2275,12 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
22582275rm -f "$TEMPLATE.bak"
22592276
22602277NEW_SCOPE=$(grep -o '"scoped_account_ids": \\[[^]]*\\]' "$TEMPLATE" | head -1 | sed 's/"scoped_account_ids": //')
2278+ if ! python3 -c "import json; json.loads('$NEW_SCOPE')" 2>/dev/null; then
2279+ echo " ❌ Scope update produced invalid JSON: $NEW_SCOPE"
2280+ echo " Update SSM parameter directly instead."
2281+ rm -f "$TEMPLATE" "$TEMPLATE.bak"
2282+ exit 1
2283+ fi
22612284sed -i.bak "s|ScopedAccounts: .*|ScopedAccounts: '$NEW_SCOPE'|" "$TEMPLATE"
22622285rm -f "$TEMPLATE.bak"
22632286
@@ -2987,12 +3010,19 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
29873010
29883011
29893012 // --- Validation ---
3013+ function isValidCalendarDate(dateStr) {
3014+ if (!dateStr) return false;
3015+ const [y, m, d] = dateStr.split('-').map(Number);
3016+ const dt = new Date(y, m - 1, d);
3017+ return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d;
3018+ }
3019+
29903020 function validate() {
29913021 let valid = true;
29923022 const mpeId = document.getElementById('mpeId').value.trim();
29933023 const date = document.getElementById('agreementDate').value;
29943024
2995- if (!/^(?=.* [A-Z])(?=.*[0-9])[A- Z0-9]{10} $/.test(mpeId)) {
3025+ if (!/^[A-Z0-9]+ $/.test(mpeId) || mpeId.length < 1 || mpeId.length > 44 ) {
29963026 document.getElementById('mpeId').classList.add('error');
29973027 document.getElementById('mpeId-error').style.display = 'block';
29983028 valid = false;
@@ -3001,7 +3031,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
30013031 document.getElementById('mpeId-error').style.display = 'none';
30023032 }
30033033
3004- if (!date) {
3034+ if (!date || !isValidCalendarDate(date) ) {
30053035 document.getElementById('agreementDate').classList.add('error');
30063036 document.getElementById('agreementDate-error').style.display = 'block';
30073037 valid = false;
@@ -3011,7 +3041,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
30113041 }
30123042
30133043 const endDate = document.getElementById('agreementEndDate').value;
3014- if (!endDate || endDate <= date) {
3044+ if (!endDate || !isValidCalendarDate(endDate) || endDate <= date) {
30153045 document.getElementById('agreementEndDate').classList.add('error');
30163046 document.getElementById('agreementEndDate-error').style.display = 'block';
30173047 valid = false;
@@ -3171,7 +3201,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
31713201 // (customer name, email, account IDs, VPC IDs) so use textContent
31723202 // rather than innerHTML template-literal interpolation. Preserves
31733203 // the main-deploy styles (width 200px, v-cell monospace 13px).
3174- // (§1.94, v21.0.3 )
3204+ // (§1.94, v21.0.4 )
31753205 table.replaceChildren();
31763206 rows.forEach(([k, v]) => {
31773207 const tr = document.createElement('tr');
@@ -7989,7 +8019,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
79898019 # — most other AWS services throw 'ThrottlingException' (with "ing").
79908020 # Both variants must be enumerated, alongside the generic rate-limit
79918021 # codes. Hoisted to module scope so _retry_throttles() and the RGTA
7992- # retry loop below share the same constant (v21.0.3 , §1.81/§1.92).
8022+ # retry loop below share the same constant (v21.0.4 , §1.81/§1.92).
79938023 THROTTLE_CODES = {'ThrottlingException', 'ThrottledException', 'RequestLimitExceeded', 'TooManyRequestsException'}
79948024
79958025 def _retry_throttles(fn):
@@ -8000,7 +8030,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
80008030 to the outer _process_event classifier on the first attempt. Absorbs
80018031 short throttles inside the invocation so we don't burn one of the
80028032 5 SQS redeliveries (180s VT each) on a recoverable rate limit
8003- (v21.0.3 , §1.81/§1.92).
8033+ (v21.0.4 , §1.81/§1.92).
80048034 """
80058035 for attempt in range(4):
80068036 try:
@@ -8026,7 +8056,7 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
80268056 # Each native tag call is wrapped in _retry_throttles() so short
80278057 # throttle bursts are absorbed in-invocation (up to 4 attempts,
80288058 # ~15s total worst case) rather than burning SQS redelivery
8029- # budget. (§1.81/§1.92, v21.0.3 )
8059+ # budget. (§1.81/§1.92, v21.0.4 )
80308060 if ':s3:::' in arn:
80318061 s3 = get_service_client('s3')
80328062 bucket = arn.split(':::')[1]
@@ -9693,6 +9723,8 @@ <h3 style="margin:20px 0 8px;font-size:14px;" data-i18n="ui_editor_script_previe
96939723 --region "\$REGION" > /dev/null
96949724 aws cloudformation wait stack-create-complete --stack-name "\$STACK_NAME" --region "\$REGION"
96959725else
9726+ echo " ⚠️ Stack \$STACK_NAME already exists (status: \$STACK_STATUS)."
9727+ echo " Updating in-place. Resources not in the new template will be deleted by CFN."
96969728 UPDATE_OUT=\$(aws cloudformation update-stack \\
96979729 --stack-name "\$STACK_NAME" \\
96989730 \$TEMPLATE_REF \\
0 commit comments