@redsift/products 12.5.12-alpha.2 → 12.5.12-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -539,7 +539,31 @@ var EXTRACTOR_STRINGS = {
539
539
  "investigate:card.dmarc.full-rejection-subdomain-weak_md": "**100%** of unauthorized emails with the [](FROM#:glossary) `{domain}` will be **rejected**, but subdomains use a weaker policy (sp={sp}), leaving them vulnerable to spoofing.",
540
540
  "investigate:card.dmarc.misplaced-bad-policy_md": "**The [FULL](DMARC_POLICY#:glossary) is misplaced and has a syntax error:**",
541
541
  "investigate:card.dmarc.misplaced-policy_md": "**The [FULL](DMARC_POLICY#:glossary) is misplaced.**",
542
- "investigate:card.dmarc.multiple-policies_md": "**More than one [FULL](DMARC_POLICY#:glossary) is published on `{policyDomain}`.** Receivers stop policy discovery and apply no DMARC policy at all, so the domain is unprotected. Publish exactly one DMARC record.",
542
+ // WEB-1243: standards-neutral. The previous wording said receivers "stop policy
543
+ // discovery and apply no DMARC policy at all", which is RFC 7489 §6.6.3 step 5 only.
544
+ // RFC 9989 §4.10 receivers discard the duplicates and CONTINUE the tree walk, so an
545
+ // ancestor policy may still apply — and DNS-DMARC-0044, the live signal on current
546
+ // production, is emitted by exactly those receivers. Stating the RFC 7489 behaviour
547
+ // as universal was therefore false for the code that now triggers this message.
548
+ // Rewritten to assert only what is true on both: the outcome is receiver-dependent
549
+ // and therefore unreliable, and the fix is the same either way.
550
+ "investigate:card.dmarc.multiple-policies_md": "**More than one [FULL](DMARC_POLICY#:glossary) is published on `{policyDomain}`.** Handling depends on the receiver: some stop policy discovery and apply no DMARC policy at all, others discard the duplicate records and continue. Your policy cannot be relied on for any recipient while more than one record is published. Publish exactly one DMARC record.",
551
+ // WEB-1243: the UNCONFIRMED counterpart. `indeterminate` means the check could not
552
+ // finish, NOT that duplicates were found. It resolves to a danger status on purpose —
553
+ // an unproven absence must not read as a proven one on a security signal — but the copy
554
+ // must not then assert the conflict as fact. `multiple-policies_md` says duplicates ARE
555
+ // published and to publish exactly one record, which on this path would tell a user
556
+ // with a healthy single record to change it.
557
+ //
558
+ // DELIBERATELY CAUSE-NEUTRAL. `indeterminate` has more than one cause: the scan budget
559
+ // running out, AND a candidate entry the check could not read (a rendered display group
560
+ // reaching a semantic position). An earlier draft said "the domain published more
561
+ // records than this check inspects", which is a FALSE statement of fact on the second
562
+ // cause — a one-name payload got told it had published too many records. Naming any
563
+ // single cause here is wrong unless the reason is carried alongside the verdict, so the
564
+ // copy states only what is true of every path: not everything was inspected, and the
565
+ // reader should go and look.
566
+ "investigate:card.dmarc.multiple-policies-unconfirmed_md": "**We could not fully check how many [FULL](DMARC_POLICY#:glossary) records are published on `{policyDomain}`.** This check did not inspect every record, so we cannot confirm whether more than one is live. If more than one is published, your policy cannot be relied on for any recipient. Review the DNS zone for `{policyDomain}` and confirm it publishes exactly one DMARC record.",
543
567
  "investigate:card.dmarc.no-policy_md": "**No [FULL](DMARC_POLICY#:glossary) published on domain**\n\n**`{policyDomain}`**",
544
568
  "investigate:card.dmarc.no-txt-published-policy-on-domain_md": "No [](TXT_RECORDS#:glossary) published on `{policyDomain}`",
545
569
  "investigate:card.dmarc.permerror-no-rua_md": "No valid reporting URI ([](RUA#:glossary)) is present, so receivers will skip DMARC processing entirely.",
@@ -1026,7 +1050,8 @@ var EXTRACTOR_STRINGS = {
1026
1050
  "investigate:card.dmarc.full-rejection-subdomain-weak_md": "**100%** des courriels non autoris\xE9s avec le [](FROM#:glossary) `{domain}` seront **rejet\xE9s**, mais les sous-domaines utilisent une politique plus faible (sp={sp}), les laissant vuln\xE9rables \xE0 l'usurpation.",
1027
1051
  "investigate:card.dmarc.misplaced-bad-policy_md": "**Le [FULL](DMARC_POLICY#:glossary) est mal plac\xE9 et a une erreur de syntaxe:**",
1028
1052
  "investigate:card.dmarc.misplaced-policy_md": "**Le [FULL](DMARC_POLICY#:glossary) est mal plac\xE9.",
1029
- "investigate:card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publi\xE9es sur `{policyDomain}`.** Les destinataires interrompent la d\xE9couverte de la politique et n'appliquent aucune politique DMARC, le domaine n'est donc pas prot\xE9g\xE9. Publiez un seul enregistrement DMARC.",
1053
+ "investigate:card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publi\xE9es sur `{policyDomain}`.** Le traitement d\xE9pend du destinataire : certains interrompent la d\xE9couverte de la politique et n'appliquent aucune politique DMARC, d'autres ignorent les enregistrements en double et continuent. Votre politique ne peut \xEAtre consid\xE9r\xE9e comme fiable pour aucun destinataire tant que plusieurs enregistrements sont publi\xE9s. Publiez un seul enregistrement DMARC.",
1054
+ "investigate:card.dmarc.multiple-policies-unconfirmed_md": "**Nous n'avons pas pu v\xE9rifier enti\xE8rement combien d'enregistrements [FULL](DMARC_POLICY#:glossary) sont publi\xE9s sur `{policyDomain}`.** Cette v\xE9rification n'a pas inspect\xE9 tous les enregistrements, nous ne pouvons donc pas confirmer si plusieurs sont actifs. Si plusieurs sont publi\xE9s, votre politique ne peut \xEAtre consid\xE9r\xE9e comme fiable pour aucun destinataire. Examinez la zone DNS de `{policyDomain}` et confirmez qu'elle publie un seul enregistrement DMARC.",
1030
1055
  "investigate:card.dmarc.no-policy_md": "**Aucune [FULL](DMARC_POLICY#:glossary) publi\xE9e sur le domaine**\n\n**`{policyDomain}`**",
1031
1056
  "investigate:card.dmarc.no-txt-published-policy-on-domain_md": "Aucun [](TXT_RECORD#:glossary) publi\xE9 sur `{policyDomain}`",
1032
1057
  "investigate:card.dmarc.permerror-no-rua_md": "Aucun URI de rapport valide ([](RUA#:glossary)) n'est pr\xE9sent, les destinataires ignoreront enti\xE8rement le traitement DMARC.",
@@ -1291,6 +1316,7 @@ var enUS = {
1291
1316
  "investigate.extracted-email-short": "Extracted from your email",
1292
1317
  "investigate.dkim-evaluation": "DKIM Evaluation",
1293
1318
  "investigate.fcrdns-evaluation": "FCrDNS Evaluation",
1319
+ "investigate.card.bimi.glossary-link": "What does this mean?",
1294
1320
  "investigate.card.spf-passed_md": "**[](SPF#:glossary) authentication passed.**",
1295
1321
  "investigate.card.spf-failed_md": "**[](SPF#:glossary) authentication failed.**",
1296
1322
  "investigate.card.dmarc.error-raw_md": [
@@ -1315,6 +1341,7 @@ var enUS = {
1315
1341
  "investigate.card.dmarc.policy-remaining-emails-pct-reject_md": "The remaining **{remainingPercent}%** of unauthorized emails will be subject to the **quarantine** policy.",
1316
1342
  "investigate.card.dmarc.policy-remaining-emails-pct-quarantine_md": "The remaining **{remainingPercent}%** of unauthorized emails will be **delivered normally**.",
1317
1343
  "investigate.card.dmarc.no-policy_md": "**No [FULL](DMARC_POLICY#:glossary) published on domain**\n\n**`{policyDomain}`**",
1344
+ "investigate.card.dmarc.multiple-policies_md": "**More than one [FULL](DMARC_POLICY#:glossary) is published on `{policyDomain}`.** Handling depends on the receiver: some stop policy discovery and apply no DMARC policy at all, others discard the duplicate records and continue. Your policy cannot be relied on for any recipient while more than one record is published. Publish exactly one DMARC record.",
1318
1345
  "investigate.card.dmarc.dmarc-passed_md": "[](DMARC#:glossary) **passed**",
1319
1346
  "investigate.card.dmarc.dmarc-passed-no-rua_md": "[](DMARC#:glossary) is BIMI compliant **BUT** it does not have an **aggregate report address** (rua) entry.",
1320
1347
  "investigate.card.dmarc.dmarc-no-rua_md": "[](DMARC#:glossary) does not have an **aggregate report address** (rua) entry.",
@@ -1934,7 +1961,14 @@ var enUS = {
1934
1961
  "investigate.card.dmarc.domain-aligned": "The domain is aligned with the From Domain. It can contribute to passing DMARC.",
1935
1962
  "investigate.card.dmarc.domain-aligned-not-verified": "The domain is aligned with the From Domain, but its signature did not verify, so it cannot contribute to passing DMARC.",
1936
1963
  "investigate.card.dmarc.spf-domain-aligned-not-verified": "The domain is aligned with the From Domain, but SPF did not pass, so it cannot contribute to passing DMARC.",
1937
- "investigate.card.dmarc.domain-not-aligned": "The domain is not aligned with the From Domain. It cannot contribute to passing DMARC."
1964
+ "investigate.card.dmarc.domain-not-aligned": "The domain is not aligned with the From Domain. It cannot contribute to passing DMARC.",
1965
+ "investigate.card.dmarc.multiple-policies-unconfirmed_md": "**We could not fully check how many [FULL](DMARC_POLICY#:glossary) records are published on `{policyDomain}`.** This check did not inspect every record, so we cannot confirm whether more than one is live. If more than one is published, your policy cannot be relied on for any recipient. Review the DNS zone for `{policyDomain}` and confirm it publishes exactly one DMARC record.",
1966
+ "investigate.card.extracted.truncated-records": "[… {count} more records not shown]",
1967
+ "investigate.card.extracted.truncated-records_one": "[… 1 more record not shown]",
1968
+ "investigate.card.extracted.truncated-records-atleast": "[… at least {count} more records not shown]",
1969
+ "investigate.card.extracted.truncated-records-uninspected": "[… further records not inspected]",
1970
+ "investigate.card.extracted.truncated-record-length": "[… long records shortened for display]",
1971
+ "investigate.card.extracted.truncated-records-atleast_one": "[… at least 1 more record not shown]"
1938
1972
  };
1939
1973
 
1940
1974
  var frFR = {
@@ -2008,6 +2042,7 @@ var frFR = {
2008
2042
  "investigate.summary.dnssec": "DNSSEC",
2009
2043
  "investigate.summary.dns-lookups": "{count} Recherches DNS",
2010
2044
  "investigate.summary.void-lookups": "{count} Recherches vides",
2045
+ "investigate.card.bimi.glossary-link": "Qu’est-ce que cela signifie ?",
2011
2046
  "investigate.card.spf-passed_md": "**[](SPF#:glossary) authentification réussie.**",
2012
2047
  "investigate.card.spf-failed_md": "**[](SPF#:glossary) authentification échouée.**",
2013
2048
  "investigate.card.dmarc.error-raw_md": [
@@ -2032,6 +2067,7 @@ var frFR = {
2032
2067
  "investigate.card.dmarc.policy-remaining-emails-pct-reject_md": "Les **{remainingPercent}%** restants de courriels non autorisés seront soumis à la politique de **mise en quarantaine**.",
2033
2068
  "investigate.card.dmarc.policy-remaining-emails-pct-quarantine_md": "Les **{remainingPercent}%** restants de courriels non autorisés seront **livrés normalement**.",
2034
2069
  "investigate.card.dmarc.no-policy_md": "**Aucune [FULL](DMARC_POLICY#:glossary) publiée sur le domaine**\n\n**`{policyDomain}`**",
2070
+ "investigate.card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publiées sur `{policyDomain}`.** Le traitement dépend du destinataire : certains interrompent la découverte de la politique et n'appliquent aucune politique DMARC, d'autres ignorent les enregistrements en double et continuent. Votre politique ne peut être considérée comme fiable pour aucun destinataire tant que plusieurs enregistrements sont publiés. Publiez un seul enregistrement DMARC.",
2035
2071
  "investigate.card.dmarc.dmarc-passed_md": "[](DMARC#:glossary) **réussi**",
2036
2072
  "investigate.card.dmarc.dmarc-passed-no-rua_md": "[](DMARC#:glossary) est conforme à BIMI **MAIS** n'a pas d'**adresse de rapport agrégé** (rua).",
2037
2073
  "investigate.card.dmarc.dmarc-no-rua_md": "[](DMARC#:glossary) n'a pas d'**adresse de rapport agrégé** (rua).",
@@ -2653,7 +2689,14 @@ var frFR = {
2653
2689
  "investigate.card.dmarc.domain-aligned": "Le domaine est aligné avec le domaine expéditeur. Il peut contribuer à la réussite de DMARC.",
2654
2690
  "investigate.card.dmarc.domain-aligned-not-verified": "Le domaine est aligné avec le domaine expéditeur, mais sa signature n'a pas été vérifiée, il ne peut donc pas contribuer à la réussite de DMARC.",
2655
2691
  "investigate.card.dmarc.spf-domain-aligned-not-verified": "Le domaine est aligné avec le domaine expéditeur, mais SPF n'a pas réussi, il ne peut donc pas contribuer à la réussite de DMARC.",
2656
- "investigate.card.dmarc.domain-not-aligned": "Le domaine n'est pas aligné avec le domaine expéditeur. Il ne peut pas contribuer à la réussite de DMARC."
2692
+ "investigate.card.dmarc.domain-not-aligned": "Le domaine n'est pas aligné avec le domaine expéditeur. Il ne peut pas contribuer à la réussite de DMARC.",
2693
+ "investigate.card.dmarc.multiple-policies-unconfirmed_md": "**Nous n'avons pas pu vérifier entièrement combien d'enregistrements [FULL](DMARC_POLICY#:glossary) sont publiés sur `{policyDomain}`.** Cette vérification n'a pas inspecté tous les enregistrements, nous ne pouvons donc pas confirmer si plusieurs sont actifs. Si plusieurs sont publiés, votre politique ne peut être considérée comme fiable pour aucun destinataire. Examinez la zone DNS de `{policyDomain}` et confirmez qu'elle publie un seul enregistrement DMARC.",
2694
+ "investigate.card.extracted.truncated-records": "[… {count} enregistrements supplémentaires non affichés]",
2695
+ "investigate.card.extracted.truncated-records_one": "[… 1 enregistrement supplémentaire non affiché]",
2696
+ "investigate.card.extracted.truncated-records-atleast": "[… au moins {count} enregistrements supplémentaires non affichés]",
2697
+ "investigate.card.extracted.truncated-records-uninspected": "[… enregistrements supplémentaires non examinés]",
2698
+ "investigate.card.extracted.truncated-record-length": "[… enregistrements longs raccourcis pour l’affichage]",
2699
+ "investigate.card.extracted.truncated-records-atleast_one": "[… au moins 1 enregistrement supplémentaire non affiché]"
2657
2700
  };
2658
2701
 
2659
2702
  /**
@@ -4826,8 +4869,49 @@ const CodeBlock = styled.div`
4826
4869
  display: none !important;
4827
4870
  }
4828
4871
  `;
4872
+
4873
+ /**
4874
+ * One logical line inside a `CodeBlock` — typically a single published DNS record.
4875
+ *
4876
+ * DS-115: a domain can publish more than one competing `_dmarc` TXT record, and the card
4877
+ * must show them as distinct records. `CodeBlock` forces `p { display: inline }`, which
4878
+ * flowed them into one unbroken line, so multi-line values use this block-level wrapper
4879
+ * instead of a `<p>`.
4880
+ */
4881
+ const CodeBlockLine = styled.div`
4882
+ display: block;
4883
+ white-space: pre-wrap;
4884
+ word-break: break-all;
4885
+
4886
+ & + & {
4887
+ margin-top: 4px;
4888
+ }
4889
+ `;
4890
+
4891
+ /**
4892
+ * Renderer commentary about a truncated literal value — NOT zone content.
4893
+ *
4894
+ * DS-115. Deliberately its own component rather than a reused `ExtractionCaption`: that one
4895
+ * is `position: absolute`, anchored to an `ExtractionBox`, so on the paths that have no
4896
+ * positioned ancestor it would escape its container, and inside a box it would land on the
4897
+ * same coordinates as the ordinary caption. This stays in normal flow directly beneath the
4898
+ * code block.
4899
+ *
4900
+ * Visually distinct from `CodeBlockLine` on purpose — italic, not monospace — because a zone
4901
+ * can publish a record whose text imitates this notice, and the two must not be confusable.
4902
+ */
4903
+ const LiteralTruncationNotice = styled.div`
4904
+ display: block;
4905
+ margin-top: 6px;
4906
+ font-style: italic;
4907
+ font-size: 12px;
4908
+ opacity: 0.75;
4909
+ word-break: break-word;
4910
+ `;
4829
4911
  ExtractionBox.displayName = 'ExtractionBox';
4830
4912
  ExtractionCaption.displayName = 'ExtractionCaption';
4913
+ CodeBlockLine.displayName = 'CodeBlockLine';
4914
+ LiteralTruncationNotice.displayName = 'LiteralTruncationNotice';
4831
4915
  InfoItemsHighlight.displayName = 'InfoItemsHighlight';
4832
4916
 
4833
4917
  // All theming uses CSS variables with Design System fallbacks (Poppins).
@@ -6640,19 +6724,244 @@ function getIcon(_ref) {
6640
6724
  size: "small"
6641
6725
  }) : null;
6642
6726
  }
6643
- const renderExtractedValue = (value, keyPrefix) => {
6727
+
6728
+ /**
6729
+ * Lines a literal (code-block) render may emit, shared across ONE CATEGORY.
6730
+ *
6731
+ * Scope is per category, not per card: `ExtractedCategoryContent` creates one budget and
6732
+ * threads it through every item and leaf it renders, so a card with N literal categories can
6733
+ * emit up to N x this figure. That is deliberate — a category is the unit a reader looks at,
6734
+ * and letting one category's pathological payload blank out a sibling section would trade a
6735
+ * bounded render for a misleading one.
6736
+ *
6737
+ * Two earlier attempts were not bounds at all. A per-leaf cap failed because
6738
+ * `renderExtractedValue` recurses into arrays (5,000 one-line entries → 5,004 nodes). A
6739
+ * per-value cap failed because upstream emits one item PER CANDIDATE GROUP, so 20 groups each
6740
+ * got a fresh 50 (→ 1,000 lines, 20 notices).
6741
+ *
6742
+ * Generous for real data: a real `_dmarc` name publishes one record and the pathological
6743
+ * production case is two.
6744
+ */
6745
+ const MAX_LITERAL_LINES = 50;
6746
+
6747
+ /**
6748
+ * Characters rendered per literal line.
6749
+ *
6750
+ * Count alone does not bound size — 50 lines of 200KB each rendered 10,001,001 characters
6751
+ * (measured). 4096 rather than upstream's 512 context bound: this is the UI, and legitimate
6752
+ * evidence exceeds 512 — a DNS TXT RR concatenates multiple 255-octet strings, and a
6753
+ * 4096-bit DKIM `p=` value alone is ~740 characters. 512 would have silently truncated real
6754
+ * keys, which on a card whose job is showing published records is a correctness bug, not a
6755
+ * safety measure.
6756
+ */
6757
+ const MAX_LITERAL_LINE_CHARS = 4096;
6758
+
6759
+ /**
6760
+ * Total literal characters per CATEGORY, independent of the per-line cap.
6761
+ *
6762
+ * The per-line cap alone still allows `MAX_LITERAL_LINES × MAX_LITERAL_LINE_CHARS`. This is
6763
+ * the ceiling that actually bounds payload size. Same per-category scope as
6764
+ * `MAX_LITERAL_LINES` — see that note.
6765
+ */
6766
+ const MAX_LITERAL_TOTAL_CHARS = 64000;
6767
+
6768
+ /**
6769
+ * Array entries VISITED per category, whatever they turn out to contain.
6770
+ *
6771
+ * Distinct from the line budget, which bounds output: entries that contribute nothing —
6772
+ * empty strings, whitespace, nulls — do not consume output quota, so a scan that stopped
6773
+ * only on the output budget could still walk a million entries. Nothing past this is read.
6774
+ */
6775
+ const MAX_LITERAL_ENTRIES_SCANNED = 2000;
6776
+
6777
+ /**
6778
+ * Shared, mutable literal render budget for one CATEGORY.
6779
+ *
6780
+ * `hiddenLines` is a LOWER BOUND, not an exact count: producing an exact figure would mean
6781
+ * scanning every withheld character of an attacker-controlled value, which is unbounded work
6782
+ * for a number nobody acts on. Copy says "at least N" / "not inspected" accordingly.
6783
+ */
6784
+
6785
+ const createLiteralBudget = () => ({
6786
+ lines: MAX_LITERAL_LINES,
6787
+ chars: MAX_LITERAL_TOTAL_CHARS,
6788
+ entries: MAX_LITERAL_ENTRIES_SCANNED,
6789
+ hiddenLines: 0,
6790
+ hiddenUncounted: false,
6791
+ shortenedLines: false
6792
+ });
6793
+ const budgetExhausted = b => b.lines <= 0 || b.chars <= 0;
6794
+
6795
+ /**
6796
+ * Truncate without splitting a surrogate pair.
6797
+ *
6798
+ * A bare `slice` cuts UTF-16 code units, so a boundary landing between a high and low
6799
+ * surrogate emits a replacement glyph — a rendering artifact in what is supposed to be a
6800
+ * verbatim record.
6801
+ */
6802
+ const sliceWholeCodePoints = (value, max) => {
6803
+ if (value.length <= max) {
6804
+ return value;
6805
+ }
6806
+ const code = value.charCodeAt(max - 1);
6807
+ // High surrogate at the boundary: its pair would be cut, so drop it too.
6808
+ const end = code >= 0xd800 && code <= 0xdbff ? max - 1 : max;
6809
+ return value.slice(0, end);
6810
+ };
6811
+
6812
+ /**
6813
+ * Render an extracted value.
6814
+ *
6815
+ * `literal` is set for code-block values (DNS TXT records and similar). Those come from
6816
+ * the inspected domain's own zone, NOT from our translations, so they are attacker-
6817
+ * controlled and must never go through `parseGlossaryMarkdown` — its documented trust
6818
+ * model assumes source-controlled input, and it turns `[text](https://...)` into a real
6819
+ * anchor. See the adversarial tests in `SignalCardExtractedItems.test.tsx`.
6820
+ */
6821
+ const renderExtractedValue = function (value, keyPrefix) {
6822
+ let literal = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
6823
+ let budget = arguments.length > 3 ? arguments[3] : undefined;
6644
6824
  const nodes = [];
6825
+ // A local budget when none is shared, so non-card callers still get a bound.
6826
+ const b = budget !== null && budget !== void 0 ? budget : createLiteralBudget();
6645
6827
  const process = (input, prefix) => {
6646
6828
  if (input === null || input === undefined) {
6647
6829
  return;
6648
6830
  }
6649
6831
  if (Array.isArray(input)) {
6650
- input.forEach((entry, idx) => process(entry, `${prefix}-${idx}`));
6832
+ for (const entry of input) {
6833
+ // Stop traversing once nothing more can be emitted: continuing would walk the whole
6834
+ // attacker-controlled array to render nothing.
6835
+ if (literal && (budgetExhausted(b) || b.entries <= 0)) {
6836
+ b.hiddenUncounted = true;
6837
+ return;
6838
+ }
6839
+ if (literal) {
6840
+ b.entries -= 1;
6841
+ }
6842
+ process(entry, `${prefix}-${nodes.length}`);
6843
+ }
6651
6844
  return;
6652
6845
  }
6653
6846
  if (typeof input === 'string') {
6654
- const trimmed = input.trim();
6655
- if (!trimmed) {
6847
+ const trimmed = literal ? undefined : input.trim();
6848
+ if (!literal && !trimmed) {
6849
+ return;
6850
+ }
6851
+
6852
+ // DS-115: in literal (code-block) mode a single string leaf may itself carry multiple
6853
+ // records separated by newlines — and so may a string nested inside an array, e.g.
6854
+ // `['record A\nrecord B']`. Split every string leaf, not just a top-level one, so each
6855
+ // record gets its own <p> (and so its own CodeBlockLine) rather than flowing together.
6856
+ if (literal) {
6857
+ // Every line becomes a React element and a styled `CodeBlockLine`, and the values are
6858
+ // attacker-controlled: DNS TXT records read from the domain under inspection.
6859
+ // Upstream deliberately does NOT bound the card path (only the context/LLM path is
6860
+ // capped) because a display cap that dropped a whole name could make the
6861
+ // multiple-records predicate deny a real conflict. That is right upstream, and it
6862
+ // makes the RENDER side the place to bound node count and byte size.
6863
+ //
6864
+ // Scanned with an index-bounded loop rather than `split().map().filter().slice()`:
6865
+ // the chained form materializes every line of an adversarial value before discarding
6866
+ // almost all of it, so the work is unbounded even though the output is not. The
6867
+ // string is never `trim()`ed as a whole either — that copies the entire input.
6868
+ let cursor = 0;
6869
+ while (cursor < input.length && !budgetExhausted(b)) {
6870
+ // Bound the scan for one line's end by what could still be rendered, so a single
6871
+ // 10-million-character line is never fully walked or normalized.
6872
+ const scanLimit = Math.min(input.length, cursor + MAX_LITERAL_LINE_CHARS + 1);
6873
+ let next = cursor;
6874
+ while (next < scanLimit && input[next] !== '\n' && input[next] !== '\r') {
6875
+ next += 1;
6876
+ }
6877
+ const overlong = next === scanLimit && next < input.length && input[next] !== '\n' && input[next] !== '\r';
6878
+ const raw = input.slice(cursor, next);
6879
+ const line = raw.replace(/\s+/g, ' ').trim();
6880
+ if (overlong) {
6881
+ // Skip this line’s untruncated remainder WITHOUT materializing it — and BOUND the skip.
6882
+ // Walking to the next newline is still O(input): a 40-million-character line with no
6883
+ // newline took ~164ms against ~97ms for 20M, so the work was linear in attacker input
6884
+ // even though the OUTPUT was bounded. Give up after a bounded probe and report the
6885
+ // rest as uninspected rather than scanning it.
6886
+ const skipLimit = cursor + MAX_LITERAL_LINE_CHARS;
6887
+ while (cursor < input.length && cursor < skipLimit && input[cursor] !== '\n' && input[cursor] !== '\r') {
6888
+ cursor += 1;
6889
+ }
6890
+ if (cursor < input.length && input[cursor] !== '\n' && input[cursor] !== '\r') {
6891
+ // The skip budget ran out before this line ended. Record that the withheld
6892
+ // figure is a floor, then stop consuming budget so the reporting block below
6893
+ // still runs from this cursor — an early `break` here escaped the loop and
6894
+ // skipped reporting entirely, leaving NO notice at all (caught by test).
6895
+ b.hiddenUncounted = true;
6896
+ b.lines = 0;
6897
+ }
6898
+ } else {
6899
+ cursor = next;
6900
+ }
6901
+
6902
+ // Advance past the whole run of newline characters, matching the previous
6903
+ // `/[\r\n]+/` split so a CRLF pair does not emit a blank line between records.
6904
+ while (cursor < input.length && (input[cursor] === '\n' || input[cursor] === '\r')) {
6905
+ cursor += 1;
6906
+ }
6907
+ if (!line) {
6908
+ continue;
6909
+ }
6910
+ const allowed = Math.min(MAX_LITERAL_LINE_CHARS, b.chars);
6911
+ const shown = sliceWholeCodePoints(line, allowed);
6912
+ if (shown.length < line.length || overlong) {
6913
+ b.shortenedLines = true;
6914
+ }
6915
+ nodes.push( /*#__PURE__*/React__default.createElement("p", {
6916
+ key: `${prefix}-text-${nodes.length}`
6917
+ }, shown));
6918
+ b.lines -= 1;
6919
+ b.chars -= shown.length;
6920
+ }
6921
+
6922
+ // Report — never silently drop. This card exists to tell someone which records to
6923
+ // remove, and a hidden remainder invites them to believe they are finished.
6924
+ //
6925
+ // The count is deliberately a LOWER BOUND: an exact figure would mean scanning every
6926
+ // withheld character of an attacker-controlled value. `hiddenUncounted` is what makes
6927
+ // the copy honest about that ("at least N"), so it MUST be set whenever the scan
6928
+ // stopped early. The previous version counted SEPARATORS rather than segments and
6929
+ // could stop on its own budget while still reporting an exact-sounding figure, so 55
6930
+ // records reported 4 withheld when 5 were (verified).
6931
+ if (cursor < input.length) {
6932
+ let hidden = 0;
6933
+ let scan = cursor;
6934
+ // Count SEGMENTS: a segment is a run of non-newline characters.
6935
+ //
6936
+ // BOTH loops are bounded. The segment count is capped by the line budget, and each
6937
+ // segment's own walk is capped by `MAX_LITERAL_LINE_CHARS` — without that inner cap a
6938
+ // remainder holding one very long newline-free line walked the whole string (20M
6939
+ // chars ≈ 42ms, linear in attacker input), which silently undid the "bound WORK, not
6940
+ // only output" guarantee the skip loop above exists to provide.
6941
+ while (scan < input.length && hidden < MAX_LITERAL_LINES) {
6942
+ while (scan < input.length && (input[scan] === '\n' || input[scan] === '\r')) {
6943
+ scan += 1;
6944
+ }
6945
+ if (scan >= input.length) {
6946
+ break;
6947
+ }
6948
+ hidden += 1;
6949
+ const segmentLimit = scan + MAX_LITERAL_LINE_CHARS;
6950
+ while (scan < input.length && scan < segmentLimit && input[scan] !== '\n' && input[scan] !== '\r') {
6951
+ scan += 1;
6952
+ }
6953
+ if (scan < input.length && input[scan] !== '\n' && input[scan] !== '\r') {
6954
+ // Gave up mid-segment, so anything past here is uncounted.
6955
+ b.hiddenUncounted = true;
6956
+ break;
6957
+ }
6958
+ }
6959
+ // Anything left unscanned means the figure is a floor, not a total.
6960
+ if (scan < input.length) {
6961
+ b.hiddenUncounted = true;
6962
+ }
6963
+ b.hiddenLines += hidden;
6964
+ }
6656
6965
  return;
6657
6966
  }
6658
6967
  nodes.push( /*#__PURE__*/React__default.createElement("p", {
@@ -6662,11 +6971,37 @@ const renderExtractedValue = (value, keyPrefix) => {
6662
6971
  }
6663
6972
  if ( /*#__PURE__*/React__default.isValidElement(input)) {
6664
6973
  var _input$key;
6974
+ // Charged against the budget too: element leaves are nodes like any other, and an
6975
+ // uncharged leaf type is an escape hatch — 5,000 object leaves previously rendered
6976
+ // 5,000 lines with no notice.
6977
+ if (literal) {
6978
+ if (budgetExhausted(b)) {
6979
+ b.hiddenLines += 1;
6980
+ return;
6981
+ }
6982
+ b.lines -= 1;
6983
+ }
6665
6984
  nodes.push( /*#__PURE__*/React__default.cloneElement(input, {
6666
6985
  key: (_input$key = input.key) !== null && _input$key !== void 0 ? _input$key : `${prefix}-element`
6667
6986
  }));
6668
6987
  return;
6669
6988
  }
6989
+
6990
+ // Numbers, booleans, objects. Stringified, so they are bounded and charged exactly like
6991
+ // string leaves rather than bypassing the budget.
6992
+ if (literal) {
6993
+ if (budgetExhausted(b)) {
6994
+ b.hiddenLines += 1;
6995
+ return;
6996
+ }
6997
+ const text = sliceWholeCodePoints(String(input), Math.min(MAX_LITERAL_LINE_CHARS, b.chars));
6998
+ b.lines -= 1;
6999
+ b.chars -= text.length;
7000
+ nodes.push( /*#__PURE__*/React__default.createElement("p", {
7001
+ key: `${prefix}-fallback`
7002
+ }, text));
7003
+ return;
7004
+ }
6670
7005
  nodes.push( /*#__PURE__*/React__default.createElement("p", {
6671
7006
  key: `${prefix}-fallback`
6672
7007
  }, String(input)));
@@ -6674,6 +7009,65 @@ const renderExtractedValue = (value, keyPrefix) => {
6674
7009
  process(value, keyPrefix);
6675
7010
  return nodes;
6676
7011
  };
7012
+
7013
+ /**
7014
+ * Wrap already-literal, newline-split nodes in a `CodeBlock`, one `CodeBlockLine` per record.
7015
+ *
7016
+ * Shared by every code-block path so they cannot drift apart: `CodeBlock` sets
7017
+ * `white-space: normal`, so without one element per line multiple records flow together.
7018
+ */
7019
+ const renderCodeBlock = nodes => /*#__PURE__*/React__default.createElement(CodeBlock, null, nodes.map((node, idx) => {
7020
+ if ( /*#__PURE__*/React__default.isValidElement(node) && node.type === 'p') {
7021
+ var _node$key;
7022
+ return /*#__PURE__*/React__default.createElement(CodeBlockLine, {
7023
+ key: (_node$key = node.key) !== null && _node$key !== void 0 ? _node$key : `code-line-${idx}`
7024
+ }, node.props.children);
7025
+ }
7026
+ return node;
7027
+ }));
7028
+
7029
+ /**
7030
+ * Notice that the renderer withheld part of a literal value.
7031
+ *
7032
+ * The caps above are initial-render SAFETY limits, not a completeness guarantee: DNS defines
7033
+ * no record-count limit for a name, a TXT RR may concatenate several 255-octet strings, and
7034
+ * under RFC 9989 §4.10 every competing record is discarded — so dozens of short records are
7035
+ * protocol-plausible and each withheld value is one the operator still has to delete.
7036
+ *
7037
+ * Reporting is therefore the minimum: truncating silently would tell someone the list is
7038
+ * complete when it is not. A "copy all published records" action was built and REVERTED (see
7039
+ * CHANGELOG) — it could not honour its own promise, so full-evidence access is tracked
7040
+ * separately rather than half-delivered here.
7041
+ *
7042
+ * Rendered OUTSIDE the `CodeBlock` and as its own `LiteralTruncationNotice`, for a security
7043
+ * reason rather than a stylistic one: inside the block it was structurally and visually
7044
+ * identical to a record, so a zone publishing the literal text `[… 3 more records not shown]`
7045
+ * produced an indistinguishable line (verified). Zone content and renderer commentary must
7046
+ * not share a presentation. `data-testid` is stable so tests can assert the notice
7047
+ * specifically, rather than matching prose an attacker could also publish.
7048
+ */
7049
+ const renderTruncationNotice = (budget, t) => {
7050
+ const parts = [];
7051
+ if (budget.hiddenLines > 0) {
7052
+ parts.push(t(budget.hiddenUncounted ? 'card.extracted.truncated-records-atleast' : 'card.extracted.truncated-records', {
7053
+ count: budget.hiddenLines
7054
+ }));
7055
+ } else if (budget.hiddenUncounted) {
7056
+ parts.push(t('card.extracted.truncated-records-uninspected'));
7057
+ }
7058
+
7059
+ // Reported ALONGSIDE the hidden count, not instead of it: a payload that both shortened
7060
+ // every visible record and hid further ones must not announce only one of the two.
7061
+ if (budget.shortenedLines) {
7062
+ parts.push(t('card.extracted.truncated-record-length'));
7063
+ }
7064
+ if (!parts.length) {
7065
+ return null;
7066
+ }
7067
+ return /*#__PURE__*/React__default.createElement(LiteralTruncationNotice, {
7068
+ "data-testid": "extracted-truncation-notice"
7069
+ }, parts.join(' '));
7070
+ };
6677
7071
  const ExtractedItem = _ref2 => {
6678
7072
  let {
6679
7073
  type,
@@ -6682,7 +7076,8 @@ const ExtractedItem = _ref2 => {
6682
7076
  text,
6683
7077
  inline = true,
6684
7078
  useCodeBlock = false,
6685
- extractionBox
7079
+ extractionBox,
7080
+ literalBudget
6686
7081
  } = _ref2;
6687
7082
  // Get translated caption for extraction box
6688
7083
  const {
@@ -6693,29 +7088,25 @@ const ExtractedItem = _ref2 => {
6693
7088
  // Parse label if it's a string with glossary markdown
6694
7089
  const parsedLabel = typeof label === 'string' ? parseGlossaryMarkdown(label) : label;
6695
7090
 
6696
- // Strip newlines from text if it's a string and we're using code block
6697
- const cleanedText = useCodeBlock && typeof text === 'string' ? text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim() : text;
6698
- const renderedText = renderExtractedValue(cleanedText, 'extracted-item-text');
7091
+ // DS-115: a code-block value may legitimately be MULTIPLE records (e.g. a domain that
7092
+ // publishes two competing `_dmarc` TXT records). Collapsing newlines into spaces ran them
7093
+ // together into one flowed line with no visible boundary, which is precisely the condition
7094
+ // the card exists to report. `renderExtractedValue`'s literal mode splits every string leaf
7095
+ // on newlines (including strings nested in arrays) so each record renders on its own line.
7096
+ const budget = literalBudget !== null && literalBudget !== void 0 ? literalBudget : createLiteralBudget();
7097
+ const renderedText = renderExtractedValue(text, 'extracted-item-text', useCodeBlock, budget);
6699
7098
  const icon = getIcon({
6700
7099
  type,
6701
7100
  theme
6702
7101
  });
6703
7102
 
6704
- // Wrap in CodeBlock if needed
6705
- const displayText = useCodeBlock ? /*#__PURE__*/React__default.createElement(CodeBlock, null, typeof renderedText === 'string' ? renderedText : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, renderedText.map(node => {
6706
- // Extract text content from p tags and flatten
6707
- if ( /*#__PURE__*/React__default.isValidElement(node) && node.type === 'p') {
6708
- const children = node.props.children;
6709
-
6710
- // If children is a string, strip ALL newlines aggressively
6711
- const cleanedChildren = typeof children === 'string' ? children.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim() : children;
6712
- return cleanedChildren;
6713
- }
6714
- return node;
6715
- }))) : renderedText;
7103
+ // Wrap in CodeBlock if needed. The truncation notice sits OUTSIDE the block — see
7104
+ // `renderTruncationNotice` on why it must never be a `CodeBlockLine`.
7105
+ const displayText = useCodeBlock ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, renderCodeBlock(renderedText), renderTruncationNotice(budget, t)) : renderedText;
7106
+ const hasContent = renderedText.length > 0;
6716
7107
  const content = /*#__PURE__*/React__default.createElement(SignalCardInfoBox$1, null, /*#__PURE__*/React__default.createElement(InfoItemRow, null, icon ? /*#__PURE__*/React__default.createElement(InfoItemIcon, null, icon) : null, /*#__PURE__*/React__default.createElement(InfoItemContent, null, /*#__PURE__*/React__default.createElement(InfoItemLabel, {
6717
7108
  $inline: true
6718
- }, parsedLabel), inline && renderedText.length ? displayText : null)), !inline && renderedText.length ? /*#__PURE__*/React__default.createElement(InfoItemSubtexts, null, displayText) : null);
7109
+ }, parsedLabel), inline && hasContent ? displayText : null)), !inline && hasContent ? /*#__PURE__*/React__default.createElement(InfoItemSubtexts, null, displayText) : null);
6719
7110
 
6720
7111
  // Wrap in extraction box if specified
6721
7112
  if (extractionBox) {
@@ -6725,9 +7116,12 @@ const ExtractedItem = _ref2 => {
6725
7116
  }
6726
7117
  return content;
6727
7118
  };
6728
- function renderGridItem(item, idx, t) {
7119
+ function renderGridItem(item, idx, t, budget) {
6729
7120
  const parsedLabel = typeof item.label === 'string' ? parseGlossaryMarkdown(item.label) : item.label;
6730
- const renderedText = renderExtractedValue(item.text, `grid-item-${idx}`);
7121
+ // DS-115: code-block values are attacker-controlled DNS data, so render them literally —
7122
+ // same trust model as `ExtractedItem`. Without this the grid path fed raw TXT records
7123
+ // through `parseGlossaryMarkdown`, which turns `[text](https://...)` into a real anchor.
7124
+ const renderedText = renderExtractedValue(item.text, `grid-item-${idx}`, !!item.useCodeBlock, budget);
6731
7125
  const icon = getIcon({
6732
7126
  type: item.type,
6733
7127
  theme: item.theme
@@ -6747,7 +7141,7 @@ function renderGridItem(item, idx, t) {
6747
7141
  display: 'inline-flex'
6748
7142
  }
6749
7143
  }, icon))) : null;
6750
- const displayValue = item.useCodeBlock ? /*#__PURE__*/React__default.createElement(CodeBlock, null, renderedText) : /*#__PURE__*/React__default.createElement(GridValue, null, renderedText, alignmentIcon);
7144
+ const displayValue = item.useCodeBlock ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, renderCodeBlock(renderedText), renderTruncationNotice(budget, t)) : /*#__PURE__*/React__default.createElement(GridValue, null, renderedText, alignmentIcon);
6751
7145
  return /*#__PURE__*/React__default.createElement(React__default.Fragment, {
6752
7146
  key: idx
6753
7147
  }, /*#__PURE__*/React__default.createElement(GridLabel, null, icon && !item.tooltipKey ? /*#__PURE__*/React__default.createElement("span", {
@@ -6766,6 +7160,15 @@ const ExtractedCategoryContent = _ref3 => {
6766
7160
  const {
6767
7161
  t
6768
7162
  } = useSignalCardTranslation('investigate');
7163
+ // ONE budget per category render: upstream emits one item per candidate group, so a
7164
+ // per-item budget let 20 groups render 20 x 50 lines with 20 separate notices.
7165
+ //
7166
+ // The notice is emitted per ITEM rather than once per category. Hoisting it was attempted
7167
+ // and reverted: the budget is mutated while the ITEMS render, and a parent cannot observe
7168
+ // that — `<Body/>` is only an element when the parent returns, so the notice read a pristine
7169
+ // budget and never appeared. Doing it properly means precomputing each category's preview
7170
+ // and truncation state before rendering, which is tracked separately (see CHANGELOG).
7171
+ const literalBudget = createLiteralBudget();
6769
7172
  const useGridLayout = category.categoryType === 'dmarc-domain' || category.categoryType === 'dmarc-policy' || category.categoryType === 'dkim-details';
6770
7173
  if (useGridLayout) {
6771
7174
  var _category$items, _category$items2;
@@ -6797,12 +7200,13 @@ const ExtractedCategoryContent = _ref3 => {
6797
7200
  text: text,
6798
7201
  inline: inline,
6799
7202
  useCodeBlock: useCodeBlock,
6800
- extractionBox: undefined
7203
+ extractionBox: undefined,
7204
+ literalBudget: literalBudget
6801
7205
  });
6802
7206
  }), /*#__PURE__*/React__default.createElement(InfoItemsGrid, {
6803
7207
  $hasNonInlineItems: hasNonInlineItems,
6804
7208
  $insideCollapsible: true
6805
- }, (_category$items5 = category.items) === null || _category$items5 === void 0 ? void 0 : _category$items5.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t))));
7209
+ }, (_category$items5 = category.items) === null || _category$items5 === void 0 ? void 0 : _category$items5.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t, literalBudget))));
6806
7210
  } else if (hasNonInlineItems) {
6807
7211
  var _category$items6, _category$items6$find, _category$items6$find2, _category$items7;
6808
7212
  // Only TXT records present
@@ -6828,7 +7232,8 @@ const ExtractedCategoryContent = _ref3 => {
6828
7232
  text: text,
6829
7233
  inline: inline,
6830
7234
  useCodeBlock: useCodeBlock,
6831
- extractionBox: undefined
7235
+ extractionBox: undefined,
7236
+ literalBudget: literalBudget
6832
7237
  });
6833
7238
  }));
6834
7239
  } else {
@@ -6839,7 +7244,7 @@ const ExtractedCategoryContent = _ref3 => {
6839
7244
  }, /*#__PURE__*/React__default.createElement(InfoItemsGrid, {
6840
7245
  $hasNonInlineItems: false,
6841
7246
  $insideCollapsible: true
6842
- }, (_category$items8 = category.items) === null || _category$items8 === void 0 ? void 0 : _category$items8.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t))));
7247
+ }, (_category$items8 = category.items) === null || _category$items8 === void 0 ? void 0 : _category$items8.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t, literalBudget))));
6843
7248
  }
6844
7249
  }
6845
7250
 
@@ -6865,7 +7270,8 @@ const ExtractedCategoryContent = _ref3 => {
6865
7270
  text: text,
6866
7271
  inline: inline,
6867
7272
  useCodeBlock: useCodeBlock,
6868
- extractionBox: undefined
7273
+ extractionBox: undefined,
7274
+ literalBudget: literalBudget
6869
7275
  });
6870
7276
  })));
6871
7277
  };
@@ -7472,9 +7878,273 @@ function renderDisplayValue(value) {
7472
7878
  return value;
7473
7879
  }
7474
7880
 
7881
+ /**
7882
+ * BIMI code → glossary page map.
7883
+ *
7884
+ * Each BIMI finding carries a `checker_codes` schema id from `redsift/event-schemas`
7885
+ * (e.g. `DNS-BIMI-0020`). The glossary publishes one explainer page per code. This
7886
+ * module turns a code into the URL of its page so a card row can offer a
7887
+ * "What does this mean?" link.
7888
+ *
7889
+ * ## Why the map lives here
7890
+ *
7891
+ * Decision D4a of the glossary design: the website deliberately publishes no
7892
+ * code → slug manifest and performs no code → URL resolution. Its side of the
7893
+ * contract is that these slugs are permanent; if one ever has to change, the
7894
+ * website owes a redirect and an updated map here.
7895
+ *
7896
+ * ## Keyed on the code, never the slug or the message text
7897
+ *
7898
+ * The code is the only stable identifier. Titles, severities and statuses drift
7899
+ * (0033 changed under WEB-1155; 0021's grammar changed) and slugs are human text,
7900
+ * so they are the most likely thing to be re-worded later. Row message text is
7901
+ * worse still — it is backend prose that `formatMessageWithUrls()` rewrites before
7902
+ * it reaches the card, and it is not in any translation catalogue.
7903
+ *
7904
+ * ## Unmapped codes resolve to nothing, by design
7905
+ *
7906
+ * `resolveBimiGlossaryHref` returns `undefined` rather than guessing a URL. This is
7907
+ * an everyday path, not an edge case:
7908
+ * - `DNS-BIMI-0003` has no glossary page — the authored set jumps 0002 → 0004.
7909
+ * - `msgCode` is a free-form string upstream; real payloads carry non-schema
7910
+ * values such as `multiple-records`, `config-error` and `DNS-BIMI-0000-MISMATCH`.
7911
+ * - IPForensics currently mis-assigns some codes (`bimi-error-invalid-record` is
7912
+ * tagged 0021, which is the "both l and a tags missed" page). A permissive
7913
+ * resolver would send a reader to a page describing a *different* error, which
7914
+ * is worse than offering no link at all.
7915
+ *
7916
+ * @see https://redsift.atlassian.net/browse/DS-113
7917
+ */
7918
+
7919
+ /** Production origin for glossary pages, used when the card renders outside the website. */
7920
+ const GLOSSARY_ORIGIN = 'https://redsift.com';
7921
+
7922
+ /** Site-relative path prefix for glossary pages. */
7923
+ const GLOSSARY_PATH = '/glossary';
7924
+
7925
+ /**
7926
+ * Code → glossary slug. Read from the published Prismic API on 2026-08-18.
7927
+ *
7928
+ * 36 entries. Regenerate from the content API rather than editing by hand, and
7929
+ * note the two known coverage gaps:
7930
+ * - 0003 is absent (no authored page).
7931
+ * - 0023–0032 are authored and live but the BIMI Checker does not emit them yet,
7932
+ * so they only appear on cards fed directly from the schema feed.
7933
+ */
7934
+ const BIMI_CODE_TO_GLOSSARY_SLUG = Object.freeze(Object.assign(Object.create(null), {
7935
+ 'DNS-BIMI-0001': 'logo-url-scheme-is-not-https-missing-vmc-in-record',
7936
+ 'DNS-BIMI-0002': 'no-vmc-in-record',
7937
+ // 0003 intentionally absent — no glossary page authored.
7938
+ 'DNS-BIMI-0004': 'missing-l-tag',
7939
+ 'DNS-BIMI-0005': 'failed-to-fetch-logo',
7940
+ 'DNS-BIMI-0006': 'no-version-in-record',
7941
+ 'DNS-BIMI-0007': 'failed-to-fetch-authority-certificate',
7942
+ 'DNS-BIMI-0008': 'no-record',
7943
+ 'DNS-BIMI-0009': 'invalid-record',
7944
+ 'DNS-BIMI-0010': 'valid-record-but-dmarc-isn-t-ready',
7945
+ 'DNS-BIMI-0011': 'vmc-validation-errors',
7946
+ 'DNS-BIMI-0012': 'certificate-can-t-be-accessed-automatically',
7947
+ 'DNS-BIMI-0013': 'declination-to-publish',
7948
+ 'DNS-BIMI-0014': 'certificate-not-valid-for-the-domain',
7949
+ 'DNS-BIMI-0015': 'bimi-record-validation-errors',
7950
+ 'DNS-BIMI-0016': 'authority-url-scheme-is-not-https',
7951
+ 'DNS-BIMI-0017': 'logo-validation-errors',
7952
+ 'DNS-BIMI-0018': 'bimi-record-found-on-organizational-domain',
7953
+ 'DNS-BIMI-0019': 'multiple-bimi-records-found-on-target-domain',
7954
+ 'DNS-BIMI-0020': 'duplicate-tag-detected-in-bimi-record',
7955
+ 'DNS-BIMI-0021': 'both-l-and-a-tags-missed',
7956
+ 'DNS-BIMI-0022': 'no-bimi-record-found-and-dmarc-policy-is-insufficient',
7957
+ 'DNS-BIMI-0023': 'svg-logo-dimensions-use-non-pixel-values',
7958
+ 'DNS-BIMI-0024': 'svg-logo-dimensions-below-96x96-pixel-minimum',
7959
+ 'DNS-BIMI-0025': 'svg-logo-missing-width-or-height-attributes',
7960
+ 'DNS-BIMI-0026': 'vmc-pem-starts-with-utf-8-bom',
7961
+ 'DNS-BIMI-0027': 'vmc-pem-bundle-contains-only-the-leaf-certificate',
7962
+ 'DNS-BIMI-0028': 'vmc-certificate-chain-signature-verification-failed',
7963
+ 'DNS-BIMI-0029': 'vmc-certificate-chain-does-not-terminate-at-a-trusted-bimi-root-ca',
7964
+ 'DNS-BIMI-0030': 'vmc-certificate-chain-ordering-does-not-match-expected-issuance-order',
7965
+ 'DNS-BIMI-0031': 'vmc-pem-bundle-omits-the-root-ca-certificate',
7966
+ 'DNS-BIMI-0032': 'non-https-url-encountered-while-fetching-the-vmc-certificate',
7967
+ 'DNS-BIMI-0033': 'vmc-leaf-certificate-selection-is-ambiguous',
7968
+ 'DNS-BIMI-0034': 'timed-out-fetching-the-bimi-asset',
7969
+ 'DNS-BIMI-0035': 'bimi-asset-host-could-not-be-resolved',
7970
+ 'DNS-BIMI-0036': 'connection-to-the-bimi-asset-host-failed',
7971
+ 'DNS-BIMI-0037': 'tls-handshake-failed-fetching-the-bimi-asset'
7972
+ }));
7973
+
7974
+ /** Options for {@link resolveBimiGlossaryHref}. */
7975
+
7976
+ /**
7977
+ * Resolve a BIMI schema code to its glossary page URL.
7978
+ *
7979
+ * @param code - A `checker_codes` schema id, e.g. `'DNS-BIMI-0020'`. Anything not
7980
+ * in {@link BIMI_CODE_TO_GLOSSARY_SLUG} resolves to `undefined`.
7981
+ * @returns The page URL, or `undefined` when the code has no glossary page.
7982
+ *
7983
+ * @example
7984
+ * resolveBimiGlossaryHref('DNS-BIMI-0008');
7985
+ * // 'https://redsift.com/glossary/no-record'
7986
+ * resolveBimiGlossaryHref('DNS-BIMI-0008', { relative: true });
7987
+ * // '/glossary/no-record'
7988
+ * resolveBimiGlossaryHref('DNS-BIMI-0003'); // undefined — no page authored
7989
+ * resolveBimiGlossaryHref('config-error'); // undefined — not a schema code
7990
+ */
7991
+ function resolveBimiGlossaryHref(code) {
7992
+ let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
7993
+ if (!code) return undefined;
7994
+
7995
+ // Own-property check, not just a truthiness test. `code` originates from `msgCode`,
7996
+ // which is free-form backend data, so a key like `__proto__` or `constructor` would
7997
+ // otherwise resolve to an inherited value — truthy, and stringified into a URL such
7998
+ // as `/glossary/[object Object]`. Both maps are null-prototyped as well; this is the
7999
+ // second layer, so reverting either one alone cannot reintroduce the hole.
8000
+ // Raised in review on PR #1172.
8001
+ if (!Object.prototype.hasOwnProperty.call(BIMI_CODE_TO_GLOSSARY_SLUG, code)) return undefined;
8002
+ const slug = BIMI_CODE_TO_GLOSSARY_SLUG[code];
8003
+ if (typeof slug !== 'string' || !slug) return undefined;
8004
+ const path = `${GLOSSARY_PATH}/${slug}`;
8005
+ return options.relative ? path : `${GLOSSARY_ORIGIN}${path}`;
8006
+ }
8007
+
8008
+ /**
8009
+ * signal-logic translation key → BIMI schema code, for rows that carry no `code`.
8010
+ *
8011
+ * The `bimiRecordStatus` switch in signal-logic builds its rows from a fixed
8012
+ * translation key and never attaches a code, so those rows cannot be linked from
8013
+ * `InfoItem.code` alone (see WEB-1239). The key, however, *is* deterministic: it is
8014
+ * a literal in signal-logic's source, not backend prose, so mapping key → code is
8015
+ * stable in a way that matching the rendered message text is not.
8016
+ *
8017
+ * Covers two families of key:
8018
+ *
8019
+ * 1. The `bimiRecordStatus` switch rows (`record-missing_md` and friends).
8020
+ * 2. The per-error `recordValidationErrors` rows, keyed `card.bimi.errors.<errorCode>`.
8021
+ * These are the individual "Record l value needs to be a valid URL" lines listed
8022
+ * under an invalid record. They matter because they are the rows that actually tell
8023
+ * a reader *what* to fix, and the schema code that IPForensics assigned them is
8024
+ * destroyed before the card sees it: the website's `RecordMessageCodes` rewrites
8025
+ * `DNS-BIMI-00XX` into these `bimi-error-*` names, collapsing 0009/0015/0020/0021
8026
+ * all onto `bimi-error-invalid-record`. Mapping the name back is therefore the only
8027
+ * way to link these rows, and it is safe only where the relation is 1:1.
8028
+ *
8029
+ * Deliberately absent:
8030
+ * - `record-failed_md` — `NOT_VMC`, `WITH_ERRORS` and `CERT_INVALID` all collapse
8031
+ * to this one row, so it is many-to-one and cannot name a single page.
8032
+ * - `record-passed_md` / `record-declination_md` are pass/info states; 0013
8033
+ * (declination) is emitted separately as a coded report row.
8034
+ * - `record-unknown-error_md` has no page.
8035
+ * - `errors.bimi-error-unsupported-values` has no authored glossary page.
8036
+ * - `errors.bimi-error-invalid-record` — the website collapses 0009, 0015, 0020 and
8037
+ * 0021 onto this one name, and those pages describe different errors: 0009 is a
8038
+ * record that cannot be parsed at all, while 0020 (duplicate tag) and 0021 (both
8039
+ * l= and a= missing) are parseable records. Mapping it to 0009 sent a reader
8040
+ * whose actual error was 0020 or 0021 to a page describing a parse failure that
8041
+ * did not happen. Raised in review on PR #1172.
8042
+ * - Most `logoErrors.invalid-svg-*` rows. These describe *which* SVG rule the logo
8043
+ * broke (bad root element, disallowed `script` element, wrong profile, …) and the
8044
+ * glossary has no page per rule — only the umbrella 0017 `logo-validation-errors`.
8045
+ * Pointing 16 distinct failures at one generic page would tell a reader nothing
8046
+ * they cannot already see on the row. `invalid-svg-missing-title` is the exception:
8047
+ * it appears on cards whose only other SVG row is the 0017 summary, so without it
8048
+ * an all-SVG failure card can have no link at all.
8049
+ * - `logoErrors.logo-extract-error` — the website collapses 0034/0035/0036/0037
8050
+ * (timeout, host unresolved, connection failed, TLS handshake failed) all onto it,
8051
+ * and each of those has its own distinct page, so it cannot name one.
8052
+ * - `pemErrors.cert-not-found` — lossy: the website maps 0002+0007 onto it, and
8053
+ * 0034–0037 land there too.
8054
+ */
8055
+ const BIMI_TRANSLATION_KEY_TO_CODE = Object.freeze(Object.assign(Object.create(null), {
8056
+ // bimiRecordStatus switch rows
8057
+ 'card.bimi.record-missing_md': 'DNS-BIMI-0008',
8058
+ // no-record
8059
+ 'card.bimi.record-invalid_md': 'DNS-BIMI-0009',
8060
+ // invalid-record
8061
+ 'card.bimi.record-no-cert_md': 'DNS-BIMI-0007',
8062
+ // failed-to-fetch-authority-certificate
8063
+ // The row's own "[here]" help link is stripped by the adapter, so the glossary page
8064
+ // is the single destination rather than competing with it.
8065
+ 'card.bimi.cert-forbidden_md': 'DNS-BIMI-0012',
8066
+ // certificate-can-t-be-accessed-automatically
8067
+ 'card.bimi.could-not-fetch_md': 'DNS-BIMI-0005',
8068
+ // failed-to-fetch-logo
8069
+ // Per-error recordValidationErrors rows
8070
+ 'card.bimi.errors.bimi-error-missing-l': 'DNS-BIMI-0004',
8071
+ // missing-l-tag
8072
+ 'card.bimi.errors.bimi-error-missing-a': 'DNS-BIMI-0002',
8073
+ // no-vmc-in-record
8074
+ 'card.bimi.errors.bimi-error-missing-v': 'DNS-BIMI-0006',
8075
+ // no-version-in-record
8076
+ 'card.bimi.errors.bimi-error-invalid-l': 'DNS-BIMI-0001',
8077
+ // logo-url-scheme-is-not-https…
8078
+ 'card.bimi.errors.bimi-error-invalid-a': 'DNS-BIMI-0016',
8079
+ // authority-url-scheme-is-not-https
8080
+ 'card.bimi.errors.bimi-error-cert-not-valid': 'DNS-BIMI-0014',
8081
+ // certificate-not-valid-for-the-domain
8082
+ 'card.bimi.errors.bimi-error-incorrect-v': 'DNS-BIMI-0006',
8083
+ // no-version-in-record — v= present but wrong
8084
+ // No page describes an unspecified error, but the row only ever appears under record
8085
+ // validation, and 0015 is that section's umbrella page — better than nothing for a
8086
+ // reader who cannot otherwise tell what to do next.
8087
+ 'card.bimi.errors.bimi-error-unknown': 'DNS-BIMI-0015',
8088
+ // bimi-record-validation-errors
8089
+ // Safe SVG section
8090
+ 'card.bimi.svg-missing-title_md': 'DNS-BIMI-0004',
8091
+ // missing-l-tag — no logo published on the record
8092
+ 'card.bimi.record-svg-added-from-itag-invalid_md': 'DNS-BIMI-0017',
8093
+ // logo-validation-errors
8094
+ // VMC/CMC certificate rows
8095
+ 'card.bimi.vmc-invalid-title_md': 'DNS-BIMI-0011',
8096
+ // vmc-validation-errors
8097
+ 'card.bimi.vmc-not-vmc-title_md': 'DNS-BIMI-0011',
8098
+ // vmc-validation-errors — not a VMC/CMC at all
8099
+ 'card.bimi.vmc.cert-ambiguous-leaf_md': 'DNS-BIMI-0033',
8100
+ // vmc-leaf-certificate-selection-is-ambiguous
8101
+ 'card.bimi.logoErrors.invalid-svg-missing-title_md': 'DNS-BIMI-0017',
8102
+ // logo-validation-errors
8103
+ // signal-logic's catch-all for an svgErrors string it has no translation for. Those
8104
+ // strings are raw fetch/parse failures ("can't fetch SVG from ...: http status not
8105
+ // OK"), which IPForensics tags extractError = DNS-BIMI-0005, so failed-to-fetch-logo
8106
+ // is the page. Without this the row renders with no link at all, since the fallback
8107
+ // branch attaches neither a code nor a per-error key.
8108
+ 'card.bimi.svg-error-fallback_md': 'DNS-BIMI-0005',
8109
+ // failed-to-fetch-logo
8110
+ // IPForensics tags the l=/certificate logo mismatch 'DNS-BIMI-0000-MISMATCH', which is
8111
+ // not a schema code and matches no page, so the code on the item cannot resolve.
8112
+ // The umbrella 0017 page covers this class, and the sibling "image with errors" row
8113
+ // on the same card already points there.
8114
+ 'card.bimi.logoErrors.logo-and-evidence-mismatch_md': 'DNS-BIMI-0017',
8115
+ // logo-validation-errors
8116
+ // VMC/PEM error rows that map 1:1
8117
+ 'card.bimi.pemErrors.cert-not-https_md': 'DNS-BIMI-0016',
8118
+ // authority-url-scheme-is-not-https
8119
+ 'card.bimi.pemErrors.cert-forbidden_md': 'DNS-BIMI-0012',
8120
+ // certificate-can-t-be-accessed-automatically
8121
+ // Matches the `bimi-error-cert-not-valid` row, which resolves to the same page.
8122
+ // Leaving this unlinked made identical "Certificate is not valid" text linked under
8123
+ // BIMI record status but not under VMC/CMC Certificate on the same card.
8124
+ 'card.bimi.pemErrors.cert-not-valid_md': 'DNS-BIMI-0014',
8125
+ // certificate-not-valid-for-the-domain
8126
+ 'card.bimi.pemErrors.cert-bad-signature_md': 'DNS-BIMI-0028' // vmc-certificate-chain-signature-verification-failed
8127
+ }));
8128
+
8129
+ /**
8130
+ * Resolve the BIMI schema code for a signal-logic translation key, for rows that
8131
+ * signal-logic derives from status alone and therefore emits without a `code`.
8132
+ *
8133
+ * @returns The schema code, or `undefined` when the key maps to no single code.
8134
+ */
8135
+ function resolveBimiCodeForTranslationKey(key) {
8136
+ if (!key) return undefined;
8137
+ // Same own-property guard as resolveBimiGlossaryHref: translation keys are literals
8138
+ // today, but this must not become the weak link if that ever changes.
8139
+ if (!Object.prototype.hasOwnProperty.call(BIMI_TRANSLATION_KEY_TO_CODE, key)) return undefined;
8140
+ const code = BIMI_TRANSLATION_KEY_TO_CODE[key];
8141
+ return typeof code === 'string' && code ? code : undefined;
8142
+ }
8143
+
7475
8144
  // =============================================================================
7476
8145
  // BIMI TRANSLATION KEY → data-testid MAPPING
7477
8146
  // =============================================================================
8147
+
7478
8148
  /**
7479
8149
  * Maps signal-logic BIMI translation keys to the data-testid values
7480
8150
  * that the old DS helpers used to set on checklist items.
@@ -7558,17 +8228,106 @@ function adaptExtractedItemCategory(cat) {
7558
8228
  };
7559
8229
  }
7560
8230
 
8231
+ /**
8232
+ * Build the markdown for a row's "What does this mean?" glossary link (DS-113).
8233
+ *
8234
+ * Returns `undefined` when the row carries no code, or a code with no glossary page.
8235
+ * The link is emitted as markdown so the existing `parseGlossaryMarkdown` path in
8236
+ * Checklist renders it — that path already validates the protocol and applies
8237
+ * `target="_blank" rel="noopener noreferrer"`, so this adds no new render surface.
8238
+ *
8239
+ * The reader never sees the raw code: `DNS-BIMI-0024` means nothing to them.
8240
+ */
8241
+ function buildGlossaryLinkMarkdown(code, t) {
8242
+ const href = resolveBimiGlossaryHref(code);
8243
+ if (!href) return undefined;
8244
+ return `[${t('card.bimi.glossary-link')}](${href})`;
8245
+ }
8246
+
8247
+ /**
8248
+ * Markdown links whose target is a "how to fix it" help article, in either of the two
8249
+ * hosts signal-logic links to.
8250
+ *
8251
+ * Matches the whole markdown link plus any leading space, so removing it leaves no gap.
8252
+ * The label is matched loosely (any text) but each href is pinned:
8253
+ * - a bare `community.redsift.com/s/article/<n>` — no `#fragment` branch, so a
8254
+ * glossary *term* link like `[BIMI Record](…/article/000001019#bimi-record)`, which
8255
+ * defines a term mid-sentence rather than offering a fix, is left intact.
8256
+ * - a `knowledge.ondmarc.redsift.com/en/articles/<slug>` — the "[here]" link on the
8257
+ * anti-bot row.
8258
+ */
8259
+ const HELP_ARTICLE_LINK = /\s*\[[^\]]*\]\((?:https:\/\/community\.redsift\.com\/s\/article\/\d+|https:\/\/knowledge\.ondmarc\.redsift\.com\/en\/articles\/[^)]+)\)/g;
8260
+
8261
+ /**
8262
+ * A trailing clause left behind once a help-article link is removed.
8263
+ *
8264
+ * Deliberately **locale-agnostic**: it cuts everything after the last sentence-ending
8265
+ * punctuation rather than matching known lead-in wording. Listing English phrases
8266
+ * would leave every other locale broken — `fr-FR` renders the anti-bot row as
8267
+ * "…anti-bot. En savoir plus et comment résoudre le problème [ici](…)", so an
8268
+ * English-only pattern strips the link and leaves the French clause dangling in front
8269
+ * of the glossary link.
8270
+ *
8271
+ * Requires a preceding sentence to keep: a row that is *only* a lead-in has nothing
8272
+ * to fall back to, so the guard in `stripHelpArticleLink` leaves it alone.
8273
+ */
8274
+ const TRAILING_CLAUSE = /([.!?](?:\*{1,2}|_{1,2}|`)?)[^.!?]*$/;
8275
+
8276
+ /**
8277
+ * Drop a row's help-article link when a glossary link replaces it.
8278
+ *
8279
+ * Both answer the same question, and the glossary page is the canonical explainer —
8280
+ * it covers what the error means *and* how to fix it — so showing both leaves the
8281
+ * reader choosing between two competing links on one line.
8282
+ *
8283
+ * A clause left stranded by the removal goes with it, in any locale. When the row has
8284
+ * no earlier sentence to fall back on, the text is left as-is minus the link rather
8285
+ * than risk emptying the row.
8286
+ */
8287
+ function stripHelpArticleLink(text) {
8288
+ const withoutLink = text.replace(HELP_ARTICLE_LINK, '');
8289
+ if (withoutLink === text) return text;
8290
+ const trimmedEnd = withoutLink.trimEnd();
8291
+ // Nothing after the final sentence break means nothing dangled — keep as-is.
8292
+ if (/[.!?]\s*$/.test(trimmedEnd)) return trimmedEnd;
8293
+ const cut = trimmedEnd.replace(TRAILING_CLAUSE, '$1');
8294
+ return cut === trimmedEnd ? trimmedEnd : cut;
8295
+ }
8296
+
7561
8297
  /**
7562
8298
  * Convert an InfoItem (DisplayValue text) → ChecklistItem-compatible shape.
8299
+ *
8300
+ * When the item carries a `code` (a `checker_codes` schema id forwarded by
8301
+ * signal-logic under WEB-1239) and that code has a glossary page, a
8302
+ * "What does this mean?" link is appended to the end of the row's own text, so it
8303
+ * reads inline rather than as a stacked second line — a card listing several
8304
+ * validation errors would otherwise double in height.
8305
+ *
8306
+ * The link can only be appended inline when the row text is a plain string. A
8307
+ * StructuredData row (charts, status lists, code blocks) has no meaningful "end of
8308
+ * the line" to append to, so those fall back to a subtext beneath the row.
8309
+ *
8310
+ * Where the row already carried a help-article link ("Learn how to fix it", or the
8311
+ * anti-bot row's "[here]"), that link is removed: the glossary page supersedes it, and
8312
+ * two links answering the same question on one line makes the reader choose between
8313
+ * them.
7563
8314
  */
7564
- function adaptInfoItem(item, testId) {
8315
+ function adaptInfoItem(item, testId, t, fallbackCode) {
8316
+ var _item$code;
8317
+ const existing = item.subtexts ? item.subtexts.map(s => ({
8318
+ text: renderDisplayValue(s.text)
8319
+ })) : undefined;
8320
+ // A code carried on the item always wins; the fallback only fills in for rows
8321
+ // signal-logic derives from status alone (DS-113).
8322
+ const link = t ? buildGlossaryLinkMarkdown((_item$code = item.code) !== null && _item$code !== void 0 ? _item$code : fallbackCode, t) : undefined;
8323
+ const inlineable = link !== undefined && typeof item.text === 'string';
7565
8324
  return _objectSpread2({
7566
8325
  type: item.type,
7567
8326
  theme: item.theme,
7568
- text: renderDisplayValue(item.text),
7569
- subtexts: item.subtexts ? item.subtexts.map(s => ({
7570
- text: renderDisplayValue(s.text)
7571
- })) : undefined
8327
+ text: renderDisplayValue(inlineable ? `${stripHelpArticleLink(item.text)} ${link}` : item.text),
8328
+ subtexts: link !== undefined && !inlineable ? [...(existing !== null && existing !== void 0 ? existing : []), {
8329
+ text: renderDisplayValue(link)
8330
+ }] : existing
7572
8331
  }, testId ? {
7573
8332
  'data-testid': testId
7574
8333
  } : undefined);
@@ -7738,12 +8497,14 @@ function adaptBimiExtractor() {
7738
8497
  const [props, t, context] = args;
7739
8498
 
7740
8499
  // Wrap t to track which translation keys map to which returned strings.
7741
- // This lets us stamp data-testid on items by matching the translated text back to its key.
8500
+ // This lets us stamp data-testid on items by matching the translated text back to its key,
8501
+ // and (DS-113) recover the BIMI schema code for status-derived rows that signal-logic
8502
+ // emits without an `InfoItem.code`.
7742
8503
  const translatedValueToKey = new Map();
7743
8504
  const trackingT = (key, params) => {
7744
8505
  const value = t(key, params);
7745
- // Only track keys that have a known testId mapping
7746
- if (BIMI_KEY_TO_TESTID[key]) {
8506
+ // Track keys that carry a testId mapping or a glossary code
8507
+ if (BIMI_KEY_TO_TESTID[key] || resolveBimiCodeForTranslationKey(key)) {
7747
8508
  translatedValueToKey.set(value, key);
7748
8509
  }
7749
8510
  return value;
@@ -7784,11 +8545,49 @@ function adaptBimiExtractor() {
7784
8545
  }
7785
8546
  return undefined;
7786
8547
  };
8548
+
8549
+ // Recover the schema code for a status-derived row (DS-113). signal-logic attaches
8550
+ // `code` only to rows built from a coded error; the `bimiRecordStatus`, validation
8551
+ // error, Safe SVG and VMC-title rows carry none, so fall back to the translation key
8552
+ // that produced the text.
8553
+ //
8554
+ // Mirrors resolveTestId's reverse CMC→VMC pass: signal-logic post-processes several
8555
+ // of these strings with localizeAssertionMarkdown() after t() returns, so the text on
8556
+ // the item no longer matches what the tracking wrapper recorded.
8557
+ const resolveFallbackCode = text => {
8558
+ const direct = resolveBimiCodeForTranslationKey(translatedValueToKey.get(text));
8559
+ if (direct) return direct;
8560
+ const vmcText = text.replace(/CMC/g, 'VMC');
8561
+ if (vmcText !== text) {
8562
+ const viaVmc = resolveBimiCodeForTranslationKey(translatedValueToKey.get(vmcText));
8563
+ if (viaVmc) return viaVmc;
8564
+ }
8565
+ return undefined;
8566
+ };
8567
+
8568
+ // A row whose message contains a URL is not a string: signal-logic runs it through
8569
+ // formatMessageWithUrls(), which returns FormattedText StructuredData with the text
8570
+ // split into segments. Flatten those segments back to a plain string so the key
8571
+ // lookups above can see rows like the svgErrors fallback ("can't fetch SVG from
8572
+ // <url>: http status not OK"), which would otherwise never resolve a code.
8573
+ const plainText = value => {
8574
+ if (typeof value === 'string') return value;
8575
+ if (!value || typeof value !== 'object') return undefined;
8576
+ const segments = value.segments;
8577
+ if (!Array.isArray(segments)) return undefined;
8578
+ const joined = segments.map(s => {
8579
+ var _s$text;
8580
+ return (_s$text = s === null || s === void 0 ? void 0 : s.text) !== null && _s$text !== void 0 ? _s$text : '';
8581
+ }).join('');
8582
+ return joined || undefined;
8583
+ };
7787
8584
  const adaptWithTestIds = categories => categories === null || categories === void 0 ? void 0 : categories.map(cat => ({
7788
8585
  title: cat.title,
7789
8586
  items: cat.items.map(item => {
7790
- const testId = typeof item.text === 'string' ? resolveTestId(item.text) : undefined;
7791
- return adaptInfoItem(item, testId);
8587
+ const text = plainText(item.text);
8588
+ const testId = text !== undefined ? resolveTestId(text) : undefined;
8589
+ const fallbackCode = text !== undefined ? resolveFallbackCode(text) : undefined;
8590
+ return adaptInfoItem(item, testId, t, fallbackCode);
7792
8591
  })
7793
8592
  }));
7794
8593
  return {
@@ -8672,20 +9471,43 @@ const buildPublishedCertificateSections = (cert, pem, t, now, assertionMarkType)
8672
9471
 
8673
9472
  // --- Verification issues ---
8674
9473
  // Sourced from PEM evidence errors plus an ambiguous leaf-selection signal.
8675
- const errorMessages = ((_pem$errors = pem === null || pem === void 0 ? void 0 : pem.errors) !== null && _pem$errors !== void 0 ? _pem$errors : []).map(e => typeof e === 'string' ? e : e === null || e === void 0 ? void 0 : e.message).filter(m => !!m);
9474
+ //
9475
+ // These rows are built here rather than in the extractor adapter, so the adapter's
9476
+ // glossary-link pass (DS-113) does not reach them. Append the link locally, keyed on
9477
+ // the same code the adapter uses, so a verification issue is explained exactly as the
9478
+ // equivalent row in the legacy VMC section is.
9479
+ const withGlossaryLink = (text, code) => {
9480
+ const href = resolveBimiGlossaryHref(code);
9481
+ return href ? `${text} [${t('card.bimi.glossary-link')}](${href})` : text;
9482
+ };
9483
+
9484
+ // pem.errors entries carry a code alongside the message (IPForensics BimiError);
9485
+ // keep it so the row can resolve its page rather than dropping to message-only.
9486
+ const errorEntries = ((_pem$errors = pem === null || pem === void 0 ? void 0 : pem.errors) !== null && _pem$errors !== void 0 ? _pem$errors : []).map(e => typeof e === 'string' ? {
9487
+ message: e,
9488
+ code: undefined
9489
+ } : {
9490
+ message: e === null || e === void 0 ? void 0 : e.message,
9491
+ code: e === null || e === void 0 ? void 0 : e.code
9492
+ }).filter(e => !!e.message);
8676
9493
  const isAmbiguous = (pem === null || pem === void 0 ? void 0 : pem.leafSelectionMethod) === 'AMBIGUOUS';
8677
9494
  const issueItems = [];
8678
9495
  if (isAmbiguous) {
9496
+ const key = 'card.bimi.vmc.cert-ambiguous-leaf_md';
8679
9497
  issueItems.push({
8680
9498
  type: 'warning',
8681
- text: t('card.bimi.vmc.cert-ambiguous-leaf_md'),
9499
+ text: withGlossaryLink(t(key), resolveBimiCodeForTranslationKey(key)),
8682
9500
  'data-testid': 'bimi-cert-issue-ambiguous'
8683
9501
  });
8684
9502
  }
8685
- errorMessages.forEach((message, idx) => {
9503
+ errorEntries.forEach((_ref2, idx) => {
9504
+ let {
9505
+ message,
9506
+ code
9507
+ } = _ref2;
8686
9508
  issueItems.push({
8687
9509
  type: 'warning',
8688
- text: message,
9510
+ text: withGlossaryLink(message, code),
8689
9511
  'data-testid': `bimi-cert-issue-${idx}`
8690
9512
  });
8691
9513
  });