@redsift/products 12.5.12-alpha.1 → 12.5.12-alpha.3

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.
@@ -292,6 +292,7 @@ var EXTRACTOR_STRINGS = {
292
292
  "card.dmarc.error.invalid-rua-format": "Invalid rua format - must be mailto: URI",
293
293
  "card.dmarc.error.invalid-version": "Invalid DMARC version - must be DMARC1",
294
294
  "card.dmarc.error.misplaced-policy": "DMARC policy located at wrong subdomain",
295
+ "card.dmarc.error.multiple-policies": "More than one DMARC record is published on this domain",
295
296
  "card.dmarc.error.nxdomain": "NXDOMAIN - domain does not exist",
296
297
  "card.dmarc.error.policy-not-found": "DMARC policy not found",
297
298
  "card.dmarc.error.syntax-error": "Syntax error in DMARC record",
@@ -538,6 +539,7 @@ var EXTRACTOR_STRINGS = {
538
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.",
539
540
  "investigate:card.dmarc.misplaced-bad-policy_md": "**The [FULL](DMARC_POLICY#:glossary) is misplaced and has a syntax error:**",
540
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.",
541
543
  "investigate:card.dmarc.no-policy_md": "**No [FULL](DMARC_POLICY#:glossary) published on domain**\n\n**`{policyDomain}`**",
542
544
  "investigate:card.dmarc.no-txt-published-policy-on-domain_md": "No [](TXT_RECORDS#:glossary) published on `{policyDomain}`",
543
545
  "investigate:card.dmarc.permerror-no-rua_md": "No valid reporting URI ([](RUA#:glossary)) is present, so receivers will skip DMARC processing entirely.",
@@ -777,6 +779,7 @@ var EXTRACTOR_STRINGS = {
777
779
  "card.dmarc.error.invalid-rua-format": "Format rua invalide - doit \xEAtre une URI mailto:",
778
780
  "card.dmarc.error.invalid-version": "Version DMARC invalide - doit \xEAtre DMARC1",
779
781
  "card.dmarc.error.misplaced-policy": "Politique DMARC situ\xE9e au mauvais sous-domaine",
782
+ "card.dmarc.error.multiple-policies": "Plusieurs enregistrements DMARC pour ce domaine",
780
783
  "card.dmarc.error.nxdomain": "NXDOMAIN - le domaine n'existe pas",
781
784
  "card.dmarc.error.policy-not-found": "Politique DMARC introuvable",
782
785
  "card.dmarc.error.syntax-error": "Erreur de syntaxe dans l'enregistrement DMARC",
@@ -1023,6 +1026,7 @@ var EXTRACTOR_STRINGS = {
1023
1026
  "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.",
1024
1027
  "investigate:card.dmarc.misplaced-bad-policy_md": "**Le [FULL](DMARC_POLICY#:glossary) est mal plac\xE9 et a une erreur de syntaxe:**",
1025
1028
  "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.",
1026
1030
  "investigate:card.dmarc.no-policy_md": "**Aucune [FULL](DMARC_POLICY#:glossary) publi\xE9e sur le domaine**\n\n**`{policyDomain}`**",
1027
1031
  "investigate:card.dmarc.no-txt-published-policy-on-domain_md": "Aucun [](TXT_RECORD#:glossary) publi\xE9 sur `{policyDomain}`",
1028
1032
  "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.",
@@ -1287,6 +1291,7 @@ var enUS = {
1287
1291
  "investigate.extracted-email-short": "Extracted from your email",
1288
1292
  "investigate.dkim-evaluation": "DKIM Evaluation",
1289
1293
  "investigate.fcrdns-evaluation": "FCrDNS Evaluation",
1294
+ "investigate.card.bimi.glossary-link": "What does this mean?",
1290
1295
  "investigate.card.spf-passed_md": "**[](SPF#:glossary) authentication passed.**",
1291
1296
  "investigate.card.spf-failed_md": "**[](SPF#:glossary) authentication failed.**",
1292
1297
  "investigate.card.dmarc.error-raw_md": [
@@ -2004,6 +2009,7 @@ var frFR = {
2004
2009
  "investigate.summary.dnssec": "DNSSEC",
2005
2010
  "investigate.summary.dns-lookups": "{count} Recherches DNS",
2006
2011
  "investigate.summary.void-lookups": "{count} Recherches vides",
2012
+ "investigate.card.bimi.glossary-link": "Qu’est-ce que cela signifie ?",
2007
2013
  "investigate.card.spf-passed_md": "**[](SPF#:glossary) authentification réussie.**",
2008
2014
  "investigate.card.spf-failed_md": "**[](SPF#:glossary) authentification échouée.**",
2009
2015
  "investigate.card.dmarc.error-raw_md": [
@@ -7468,9 +7474,273 @@ function renderDisplayValue(value) {
7468
7474
  return value;
7469
7475
  }
7470
7476
 
7477
+ /**
7478
+ * BIMI code → glossary page map.
7479
+ *
7480
+ * Each BIMI finding carries a `checker_codes` schema id from `redsift/event-schemas`
7481
+ * (e.g. `DNS-BIMI-0020`). The glossary publishes one explainer page per code. This
7482
+ * module turns a code into the URL of its page so a card row can offer a
7483
+ * "What does this mean?" link.
7484
+ *
7485
+ * ## Why the map lives here
7486
+ *
7487
+ * Decision D4a of the glossary design: the website deliberately publishes no
7488
+ * code → slug manifest and performs no code → URL resolution. Its side of the
7489
+ * contract is that these slugs are permanent; if one ever has to change, the
7490
+ * website owes a redirect and an updated map here.
7491
+ *
7492
+ * ## Keyed on the code, never the slug or the message text
7493
+ *
7494
+ * The code is the only stable identifier. Titles, severities and statuses drift
7495
+ * (0033 changed under WEB-1155; 0021's grammar changed) and slugs are human text,
7496
+ * so they are the most likely thing to be re-worded later. Row message text is
7497
+ * worse still — it is backend prose that `formatMessageWithUrls()` rewrites before
7498
+ * it reaches the card, and it is not in any translation catalogue.
7499
+ *
7500
+ * ## Unmapped codes resolve to nothing, by design
7501
+ *
7502
+ * `resolveBimiGlossaryHref` returns `undefined` rather than guessing a URL. This is
7503
+ * an everyday path, not an edge case:
7504
+ * - `DNS-BIMI-0003` has no glossary page — the authored set jumps 0002 → 0004.
7505
+ * - `msgCode` is a free-form string upstream; real payloads carry non-schema
7506
+ * values such as `multiple-records`, `config-error` and `DNS-BIMI-0000-MISMATCH`.
7507
+ * - IPForensics currently mis-assigns some codes (`bimi-error-invalid-record` is
7508
+ * tagged 0021, which is the "both l and a tags missed" page). A permissive
7509
+ * resolver would send a reader to a page describing a *different* error, which
7510
+ * is worse than offering no link at all.
7511
+ *
7512
+ * @see https://redsift.atlassian.net/browse/DS-113
7513
+ */
7514
+
7515
+ /** Production origin for glossary pages, used when the card renders outside the website. */
7516
+ const GLOSSARY_ORIGIN = 'https://redsift.com';
7517
+
7518
+ /** Site-relative path prefix for glossary pages. */
7519
+ const GLOSSARY_PATH = '/glossary';
7520
+
7521
+ /**
7522
+ * Code → glossary slug. Read from the published Prismic API on 2026-08-18.
7523
+ *
7524
+ * 36 entries. Regenerate from the content API rather than editing by hand, and
7525
+ * note the two known coverage gaps:
7526
+ * - 0003 is absent (no authored page).
7527
+ * - 0023–0032 are authored and live but the BIMI Checker does not emit them yet,
7528
+ * so they only appear on cards fed directly from the schema feed.
7529
+ */
7530
+ const BIMI_CODE_TO_GLOSSARY_SLUG = Object.freeze(Object.assign(Object.create(null), {
7531
+ 'DNS-BIMI-0001': 'logo-url-scheme-is-not-https-missing-vmc-in-record',
7532
+ 'DNS-BIMI-0002': 'no-vmc-in-record',
7533
+ // 0003 intentionally absent — no glossary page authored.
7534
+ 'DNS-BIMI-0004': 'missing-l-tag',
7535
+ 'DNS-BIMI-0005': 'failed-to-fetch-logo',
7536
+ 'DNS-BIMI-0006': 'no-version-in-record',
7537
+ 'DNS-BIMI-0007': 'failed-to-fetch-authority-certificate',
7538
+ 'DNS-BIMI-0008': 'no-record',
7539
+ 'DNS-BIMI-0009': 'invalid-record',
7540
+ 'DNS-BIMI-0010': 'valid-record-but-dmarc-isn-t-ready',
7541
+ 'DNS-BIMI-0011': 'vmc-validation-errors',
7542
+ 'DNS-BIMI-0012': 'certificate-can-t-be-accessed-automatically',
7543
+ 'DNS-BIMI-0013': 'declination-to-publish',
7544
+ 'DNS-BIMI-0014': 'certificate-not-valid-for-the-domain',
7545
+ 'DNS-BIMI-0015': 'bimi-record-validation-errors',
7546
+ 'DNS-BIMI-0016': 'authority-url-scheme-is-not-https',
7547
+ 'DNS-BIMI-0017': 'logo-validation-errors',
7548
+ 'DNS-BIMI-0018': 'bimi-record-found-on-organizational-domain',
7549
+ 'DNS-BIMI-0019': 'multiple-bimi-records-found-on-target-domain',
7550
+ 'DNS-BIMI-0020': 'duplicate-tag-detected-in-bimi-record',
7551
+ 'DNS-BIMI-0021': 'both-l-and-a-tags-missed',
7552
+ 'DNS-BIMI-0022': 'no-bimi-record-found-and-dmarc-policy-is-insufficient',
7553
+ 'DNS-BIMI-0023': 'svg-logo-dimensions-use-non-pixel-values',
7554
+ 'DNS-BIMI-0024': 'svg-logo-dimensions-below-96x96-pixel-minimum',
7555
+ 'DNS-BIMI-0025': 'svg-logo-missing-width-or-height-attributes',
7556
+ 'DNS-BIMI-0026': 'vmc-pem-starts-with-utf-8-bom',
7557
+ 'DNS-BIMI-0027': 'vmc-pem-bundle-contains-only-the-leaf-certificate',
7558
+ 'DNS-BIMI-0028': 'vmc-certificate-chain-signature-verification-failed',
7559
+ 'DNS-BIMI-0029': 'vmc-certificate-chain-does-not-terminate-at-a-trusted-bimi-root-ca',
7560
+ 'DNS-BIMI-0030': 'vmc-certificate-chain-ordering-does-not-match-expected-issuance-order',
7561
+ 'DNS-BIMI-0031': 'vmc-pem-bundle-omits-the-root-ca-certificate',
7562
+ 'DNS-BIMI-0032': 'non-https-url-encountered-while-fetching-the-vmc-certificate',
7563
+ 'DNS-BIMI-0033': 'vmc-leaf-certificate-selection-is-ambiguous',
7564
+ 'DNS-BIMI-0034': 'timed-out-fetching-the-bimi-asset',
7565
+ 'DNS-BIMI-0035': 'bimi-asset-host-could-not-be-resolved',
7566
+ 'DNS-BIMI-0036': 'connection-to-the-bimi-asset-host-failed',
7567
+ 'DNS-BIMI-0037': 'tls-handshake-failed-fetching-the-bimi-asset'
7568
+ }));
7569
+
7570
+ /** Options for {@link resolveBimiGlossaryHref}. */
7571
+
7572
+ /**
7573
+ * Resolve a BIMI schema code to its glossary page URL.
7574
+ *
7575
+ * @param code - A `checker_codes` schema id, e.g. `'DNS-BIMI-0020'`. Anything not
7576
+ * in {@link BIMI_CODE_TO_GLOSSARY_SLUG} resolves to `undefined`.
7577
+ * @returns The page URL, or `undefined` when the code has no glossary page.
7578
+ *
7579
+ * @example
7580
+ * resolveBimiGlossaryHref('DNS-BIMI-0008');
7581
+ * // 'https://redsift.com/glossary/no-record'
7582
+ * resolveBimiGlossaryHref('DNS-BIMI-0008', { relative: true });
7583
+ * // '/glossary/no-record'
7584
+ * resolveBimiGlossaryHref('DNS-BIMI-0003'); // undefined — no page authored
7585
+ * resolveBimiGlossaryHref('config-error'); // undefined — not a schema code
7586
+ */
7587
+ function resolveBimiGlossaryHref(code) {
7588
+ let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
7589
+ if (!code) return undefined;
7590
+
7591
+ // Own-property check, not just a truthiness test. `code` originates from `msgCode`,
7592
+ // which is free-form backend data, so a key like `__proto__` or `constructor` would
7593
+ // otherwise resolve to an inherited value — truthy, and stringified into a URL such
7594
+ // as `/glossary/[object Object]`. Both maps are null-prototyped as well; this is the
7595
+ // second layer, so reverting either one alone cannot reintroduce the hole.
7596
+ // Raised in review on PR #1172.
7597
+ if (!Object.prototype.hasOwnProperty.call(BIMI_CODE_TO_GLOSSARY_SLUG, code)) return undefined;
7598
+ const slug = BIMI_CODE_TO_GLOSSARY_SLUG[code];
7599
+ if (typeof slug !== 'string' || !slug) return undefined;
7600
+ const path = `${GLOSSARY_PATH}/${slug}`;
7601
+ return options.relative ? path : `${GLOSSARY_ORIGIN}${path}`;
7602
+ }
7603
+
7604
+ /**
7605
+ * signal-logic translation key → BIMI schema code, for rows that carry no `code`.
7606
+ *
7607
+ * The `bimiRecordStatus` switch in signal-logic builds its rows from a fixed
7608
+ * translation key and never attaches a code, so those rows cannot be linked from
7609
+ * `InfoItem.code` alone (see WEB-1239). The key, however, *is* deterministic: it is
7610
+ * a literal in signal-logic's source, not backend prose, so mapping key → code is
7611
+ * stable in a way that matching the rendered message text is not.
7612
+ *
7613
+ * Covers two families of key:
7614
+ *
7615
+ * 1. The `bimiRecordStatus` switch rows (`record-missing_md` and friends).
7616
+ * 2. The per-error `recordValidationErrors` rows, keyed `card.bimi.errors.<errorCode>`.
7617
+ * These are the individual "Record l value needs to be a valid URL" lines listed
7618
+ * under an invalid record. They matter because they are the rows that actually tell
7619
+ * a reader *what* to fix, and the schema code that IPForensics assigned them is
7620
+ * destroyed before the card sees it: the website's `RecordMessageCodes` rewrites
7621
+ * `DNS-BIMI-00XX` into these `bimi-error-*` names, collapsing 0009/0015/0020/0021
7622
+ * all onto `bimi-error-invalid-record`. Mapping the name back is therefore the only
7623
+ * way to link these rows, and it is safe only where the relation is 1:1.
7624
+ *
7625
+ * Deliberately absent:
7626
+ * - `record-failed_md` — `NOT_VMC`, `WITH_ERRORS` and `CERT_INVALID` all collapse
7627
+ * to this one row, so it is many-to-one and cannot name a single page.
7628
+ * - `record-passed_md` / `record-declination_md` are pass/info states; 0013
7629
+ * (declination) is emitted separately as a coded report row.
7630
+ * - `record-unknown-error_md` has no page.
7631
+ * - `errors.bimi-error-unsupported-values` has no authored glossary page.
7632
+ * - `errors.bimi-error-invalid-record` — the website collapses 0009, 0015, 0020 and
7633
+ * 0021 onto this one name, and those pages describe different errors: 0009 is a
7634
+ * record that cannot be parsed at all, while 0020 (duplicate tag) and 0021 (both
7635
+ * l= and a= missing) are parseable records. Mapping it to 0009 sent a reader
7636
+ * whose actual error was 0020 or 0021 to a page describing a parse failure that
7637
+ * did not happen. Raised in review on PR #1172.
7638
+ * - Most `logoErrors.invalid-svg-*` rows. These describe *which* SVG rule the logo
7639
+ * broke (bad root element, disallowed `script` element, wrong profile, …) and the
7640
+ * glossary has no page per rule — only the umbrella 0017 `logo-validation-errors`.
7641
+ * Pointing 16 distinct failures at one generic page would tell a reader nothing
7642
+ * they cannot already see on the row. `invalid-svg-missing-title` is the exception:
7643
+ * it appears on cards whose only other SVG row is the 0017 summary, so without it
7644
+ * an all-SVG failure card can have no link at all.
7645
+ * - `logoErrors.logo-extract-error` — the website collapses 0034/0035/0036/0037
7646
+ * (timeout, host unresolved, connection failed, TLS handshake failed) all onto it,
7647
+ * and each of those has its own distinct page, so it cannot name one.
7648
+ * - `pemErrors.cert-not-found` — lossy: the website maps 0002+0007 onto it, and
7649
+ * 0034–0037 land there too.
7650
+ */
7651
+ const BIMI_TRANSLATION_KEY_TO_CODE = Object.freeze(Object.assign(Object.create(null), {
7652
+ // bimiRecordStatus switch rows
7653
+ 'card.bimi.record-missing_md': 'DNS-BIMI-0008',
7654
+ // no-record
7655
+ 'card.bimi.record-invalid_md': 'DNS-BIMI-0009',
7656
+ // invalid-record
7657
+ 'card.bimi.record-no-cert_md': 'DNS-BIMI-0007',
7658
+ // failed-to-fetch-authority-certificate
7659
+ // The row's own "[here]" help link is stripped by the adapter, so the glossary page
7660
+ // is the single destination rather than competing with it.
7661
+ 'card.bimi.cert-forbidden_md': 'DNS-BIMI-0012',
7662
+ // certificate-can-t-be-accessed-automatically
7663
+ 'card.bimi.could-not-fetch_md': 'DNS-BIMI-0005',
7664
+ // failed-to-fetch-logo
7665
+ // Per-error recordValidationErrors rows
7666
+ 'card.bimi.errors.bimi-error-missing-l': 'DNS-BIMI-0004',
7667
+ // missing-l-tag
7668
+ 'card.bimi.errors.bimi-error-missing-a': 'DNS-BIMI-0002',
7669
+ // no-vmc-in-record
7670
+ 'card.bimi.errors.bimi-error-missing-v': 'DNS-BIMI-0006',
7671
+ // no-version-in-record
7672
+ 'card.bimi.errors.bimi-error-invalid-l': 'DNS-BIMI-0001',
7673
+ // logo-url-scheme-is-not-https…
7674
+ 'card.bimi.errors.bimi-error-invalid-a': 'DNS-BIMI-0016',
7675
+ // authority-url-scheme-is-not-https
7676
+ 'card.bimi.errors.bimi-error-cert-not-valid': 'DNS-BIMI-0014',
7677
+ // certificate-not-valid-for-the-domain
7678
+ 'card.bimi.errors.bimi-error-incorrect-v': 'DNS-BIMI-0006',
7679
+ // no-version-in-record — v= present but wrong
7680
+ // No page describes an unspecified error, but the row only ever appears under record
7681
+ // validation, and 0015 is that section's umbrella page — better than nothing for a
7682
+ // reader who cannot otherwise tell what to do next.
7683
+ 'card.bimi.errors.bimi-error-unknown': 'DNS-BIMI-0015',
7684
+ // bimi-record-validation-errors
7685
+ // Safe SVG section
7686
+ 'card.bimi.svg-missing-title_md': 'DNS-BIMI-0004',
7687
+ // missing-l-tag — no logo published on the record
7688
+ 'card.bimi.record-svg-added-from-itag-invalid_md': 'DNS-BIMI-0017',
7689
+ // logo-validation-errors
7690
+ // VMC/CMC certificate rows
7691
+ 'card.bimi.vmc-invalid-title_md': 'DNS-BIMI-0011',
7692
+ // vmc-validation-errors
7693
+ 'card.bimi.vmc-not-vmc-title_md': 'DNS-BIMI-0011',
7694
+ // vmc-validation-errors — not a VMC/CMC at all
7695
+ 'card.bimi.vmc.cert-ambiguous-leaf_md': 'DNS-BIMI-0033',
7696
+ // vmc-leaf-certificate-selection-is-ambiguous
7697
+ 'card.bimi.logoErrors.invalid-svg-missing-title_md': 'DNS-BIMI-0017',
7698
+ // logo-validation-errors
7699
+ // signal-logic's catch-all for an svgErrors string it has no translation for. Those
7700
+ // strings are raw fetch/parse failures ("can't fetch SVG from ...: http status not
7701
+ // OK"), which IPForensics tags extractError = DNS-BIMI-0005, so failed-to-fetch-logo
7702
+ // is the page. Without this the row renders with no link at all, since the fallback
7703
+ // branch attaches neither a code nor a per-error key.
7704
+ 'card.bimi.svg-error-fallback_md': 'DNS-BIMI-0005',
7705
+ // failed-to-fetch-logo
7706
+ // IPForensics tags the l=/certificate logo mismatch 'DNS-BIMI-0000-MISMATCH', which is
7707
+ // not a schema code and matches no page, so the code on the item cannot resolve.
7708
+ // The umbrella 0017 page covers this class, and the sibling "image with errors" row
7709
+ // on the same card already points there.
7710
+ 'card.bimi.logoErrors.logo-and-evidence-mismatch_md': 'DNS-BIMI-0017',
7711
+ // logo-validation-errors
7712
+ // VMC/PEM error rows that map 1:1
7713
+ 'card.bimi.pemErrors.cert-not-https_md': 'DNS-BIMI-0016',
7714
+ // authority-url-scheme-is-not-https
7715
+ 'card.bimi.pemErrors.cert-forbidden_md': 'DNS-BIMI-0012',
7716
+ // certificate-can-t-be-accessed-automatically
7717
+ // Matches the `bimi-error-cert-not-valid` row, which resolves to the same page.
7718
+ // Leaving this unlinked made identical "Certificate is not valid" text linked under
7719
+ // BIMI record status but not under VMC/CMC Certificate on the same card.
7720
+ 'card.bimi.pemErrors.cert-not-valid_md': 'DNS-BIMI-0014',
7721
+ // certificate-not-valid-for-the-domain
7722
+ 'card.bimi.pemErrors.cert-bad-signature_md': 'DNS-BIMI-0028' // vmc-certificate-chain-signature-verification-failed
7723
+ }));
7724
+
7725
+ /**
7726
+ * Resolve the BIMI schema code for a signal-logic translation key, for rows that
7727
+ * signal-logic derives from status alone and therefore emits without a `code`.
7728
+ *
7729
+ * @returns The schema code, or `undefined` when the key maps to no single code.
7730
+ */
7731
+ function resolveBimiCodeForTranslationKey(key) {
7732
+ if (!key) return undefined;
7733
+ // Same own-property guard as resolveBimiGlossaryHref: translation keys are literals
7734
+ // today, but this must not become the weak link if that ever changes.
7735
+ if (!Object.prototype.hasOwnProperty.call(BIMI_TRANSLATION_KEY_TO_CODE, key)) return undefined;
7736
+ const code = BIMI_TRANSLATION_KEY_TO_CODE[key];
7737
+ return typeof code === 'string' && code ? code : undefined;
7738
+ }
7739
+
7471
7740
  // =============================================================================
7472
7741
  // BIMI TRANSLATION KEY → data-testid MAPPING
7473
7742
  // =============================================================================
7743
+
7474
7744
  /**
7475
7745
  * Maps signal-logic BIMI translation keys to the data-testid values
7476
7746
  * that the old DS helpers used to set on checklist items.
@@ -7554,17 +7824,106 @@ function adaptExtractedItemCategory(cat) {
7554
7824
  };
7555
7825
  }
7556
7826
 
7827
+ /**
7828
+ * Build the markdown for a row's "What does this mean?" glossary link (DS-113).
7829
+ *
7830
+ * Returns `undefined` when the row carries no code, or a code with no glossary page.
7831
+ * The link is emitted as markdown so the existing `parseGlossaryMarkdown` path in
7832
+ * Checklist renders it — that path already validates the protocol and applies
7833
+ * `target="_blank" rel="noopener noreferrer"`, so this adds no new render surface.
7834
+ *
7835
+ * The reader never sees the raw code: `DNS-BIMI-0024` means nothing to them.
7836
+ */
7837
+ function buildGlossaryLinkMarkdown(code, t) {
7838
+ const href = resolveBimiGlossaryHref(code);
7839
+ if (!href) return undefined;
7840
+ return `[${t('card.bimi.glossary-link')}](${href})`;
7841
+ }
7842
+
7843
+ /**
7844
+ * Markdown links whose target is a "how to fix it" help article, in either of the two
7845
+ * hosts signal-logic links to.
7846
+ *
7847
+ * Matches the whole markdown link plus any leading space, so removing it leaves no gap.
7848
+ * The label is matched loosely (any text) but each href is pinned:
7849
+ * - a bare `community.redsift.com/s/article/<n>` — no `#fragment` branch, so a
7850
+ * glossary *term* link like `[BIMI Record](…/article/000001019#bimi-record)`, which
7851
+ * defines a term mid-sentence rather than offering a fix, is left intact.
7852
+ * - a `knowledge.ondmarc.redsift.com/en/articles/<slug>` — the "[here]" link on the
7853
+ * anti-bot row.
7854
+ */
7855
+ const HELP_ARTICLE_LINK = /\s*\[[^\]]*\]\((?:https:\/\/community\.redsift\.com\/s\/article\/\d+|https:\/\/knowledge\.ondmarc\.redsift\.com\/en\/articles\/[^)]+)\)/g;
7856
+
7857
+ /**
7858
+ * A trailing clause left behind once a help-article link is removed.
7859
+ *
7860
+ * Deliberately **locale-agnostic**: it cuts everything after the last sentence-ending
7861
+ * punctuation rather than matching known lead-in wording. Listing English phrases
7862
+ * would leave every other locale broken — `fr-FR` renders the anti-bot row as
7863
+ * "…anti-bot. En savoir plus et comment résoudre le problème [ici](…)", so an
7864
+ * English-only pattern strips the link and leaves the French clause dangling in front
7865
+ * of the glossary link.
7866
+ *
7867
+ * Requires a preceding sentence to keep: a row that is *only* a lead-in has nothing
7868
+ * to fall back to, so the guard in `stripHelpArticleLink` leaves it alone.
7869
+ */
7870
+ const TRAILING_CLAUSE = /([.!?](?:\*{1,2}|_{1,2}|`)?)[^.!?]*$/;
7871
+
7872
+ /**
7873
+ * Drop a row's help-article link when a glossary link replaces it.
7874
+ *
7875
+ * Both answer the same question, and the glossary page is the canonical explainer —
7876
+ * it covers what the error means *and* how to fix it — so showing both leaves the
7877
+ * reader choosing between two competing links on one line.
7878
+ *
7879
+ * A clause left stranded by the removal goes with it, in any locale. When the row has
7880
+ * no earlier sentence to fall back on, the text is left as-is minus the link rather
7881
+ * than risk emptying the row.
7882
+ */
7883
+ function stripHelpArticleLink(text) {
7884
+ const withoutLink = text.replace(HELP_ARTICLE_LINK, '');
7885
+ if (withoutLink === text) return text;
7886
+ const trimmedEnd = withoutLink.trimEnd();
7887
+ // Nothing after the final sentence break means nothing dangled — keep as-is.
7888
+ if (/[.!?]\s*$/.test(trimmedEnd)) return trimmedEnd;
7889
+ const cut = trimmedEnd.replace(TRAILING_CLAUSE, '$1');
7890
+ return cut === trimmedEnd ? trimmedEnd : cut;
7891
+ }
7892
+
7557
7893
  /**
7558
7894
  * Convert an InfoItem (DisplayValue text) → ChecklistItem-compatible shape.
7895
+ *
7896
+ * When the item carries a `code` (a `checker_codes` schema id forwarded by
7897
+ * signal-logic under WEB-1239) and that code has a glossary page, a
7898
+ * "What does this mean?" link is appended to the end of the row's own text, so it
7899
+ * reads inline rather than as a stacked second line — a card listing several
7900
+ * validation errors would otherwise double in height.
7901
+ *
7902
+ * The link can only be appended inline when the row text is a plain string. A
7903
+ * StructuredData row (charts, status lists, code blocks) has no meaningful "end of
7904
+ * the line" to append to, so those fall back to a subtext beneath the row.
7905
+ *
7906
+ * Where the row already carried a help-article link ("Learn how to fix it", or the
7907
+ * anti-bot row's "[here]"), that link is removed: the glossary page supersedes it, and
7908
+ * two links answering the same question on one line makes the reader choose between
7909
+ * them.
7559
7910
  */
7560
- function adaptInfoItem(item, testId) {
7911
+ function adaptInfoItem(item, testId, t, fallbackCode) {
7912
+ var _item$code;
7913
+ const existing = item.subtexts ? item.subtexts.map(s => ({
7914
+ text: renderDisplayValue(s.text)
7915
+ })) : undefined;
7916
+ // A code carried on the item always wins; the fallback only fills in for rows
7917
+ // signal-logic derives from status alone (DS-113).
7918
+ const link = t ? buildGlossaryLinkMarkdown((_item$code = item.code) !== null && _item$code !== void 0 ? _item$code : fallbackCode, t) : undefined;
7919
+ const inlineable = link !== undefined && typeof item.text === 'string';
7561
7920
  return _objectSpread2({
7562
7921
  type: item.type,
7563
7922
  theme: item.theme,
7564
- text: renderDisplayValue(item.text),
7565
- subtexts: item.subtexts ? item.subtexts.map(s => ({
7566
- text: renderDisplayValue(s.text)
7567
- })) : undefined
7923
+ text: renderDisplayValue(inlineable ? `${stripHelpArticleLink(item.text)} ${link}` : item.text),
7924
+ subtexts: link !== undefined && !inlineable ? [...(existing !== null && existing !== void 0 ? existing : []), {
7925
+ text: renderDisplayValue(link)
7926
+ }] : existing
7568
7927
  }, testId ? {
7569
7928
  'data-testid': testId
7570
7929
  } : undefined);
@@ -7734,12 +8093,14 @@ function adaptBimiExtractor() {
7734
8093
  const [props, t, context] = args;
7735
8094
 
7736
8095
  // Wrap t to track which translation keys map to which returned strings.
7737
- // This lets us stamp data-testid on items by matching the translated text back to its key.
8096
+ // This lets us stamp data-testid on items by matching the translated text back to its key,
8097
+ // and (DS-113) recover the BIMI schema code for status-derived rows that signal-logic
8098
+ // emits without an `InfoItem.code`.
7738
8099
  const translatedValueToKey = new Map();
7739
8100
  const trackingT = (key, params) => {
7740
8101
  const value = t(key, params);
7741
- // Only track keys that have a known testId mapping
7742
- if (BIMI_KEY_TO_TESTID[key]) {
8102
+ // Track keys that carry a testId mapping or a glossary code
8103
+ if (BIMI_KEY_TO_TESTID[key] || resolveBimiCodeForTranslationKey(key)) {
7743
8104
  translatedValueToKey.set(value, key);
7744
8105
  }
7745
8106
  return value;
@@ -7780,11 +8141,49 @@ function adaptBimiExtractor() {
7780
8141
  }
7781
8142
  return undefined;
7782
8143
  };
8144
+
8145
+ // Recover the schema code for a status-derived row (DS-113). signal-logic attaches
8146
+ // `code` only to rows built from a coded error; the `bimiRecordStatus`, validation
8147
+ // error, Safe SVG and VMC-title rows carry none, so fall back to the translation key
8148
+ // that produced the text.
8149
+ //
8150
+ // Mirrors resolveTestId's reverse CMC→VMC pass: signal-logic post-processes several
8151
+ // of these strings with localizeAssertionMarkdown() after t() returns, so the text on
8152
+ // the item no longer matches what the tracking wrapper recorded.
8153
+ const resolveFallbackCode = text => {
8154
+ const direct = resolveBimiCodeForTranslationKey(translatedValueToKey.get(text));
8155
+ if (direct) return direct;
8156
+ const vmcText = text.replace(/CMC/g, 'VMC');
8157
+ if (vmcText !== text) {
8158
+ const viaVmc = resolveBimiCodeForTranslationKey(translatedValueToKey.get(vmcText));
8159
+ if (viaVmc) return viaVmc;
8160
+ }
8161
+ return undefined;
8162
+ };
8163
+
8164
+ // A row whose message contains a URL is not a string: signal-logic runs it through
8165
+ // formatMessageWithUrls(), which returns FormattedText StructuredData with the text
8166
+ // split into segments. Flatten those segments back to a plain string so the key
8167
+ // lookups above can see rows like the svgErrors fallback ("can't fetch SVG from
8168
+ // <url>: http status not OK"), which would otherwise never resolve a code.
8169
+ const plainText = value => {
8170
+ if (typeof value === 'string') return value;
8171
+ if (!value || typeof value !== 'object') return undefined;
8172
+ const segments = value.segments;
8173
+ if (!Array.isArray(segments)) return undefined;
8174
+ const joined = segments.map(s => {
8175
+ var _s$text;
8176
+ return (_s$text = s === null || s === void 0 ? void 0 : s.text) !== null && _s$text !== void 0 ? _s$text : '';
8177
+ }).join('');
8178
+ return joined || undefined;
8179
+ };
7783
8180
  const adaptWithTestIds = categories => categories === null || categories === void 0 ? void 0 : categories.map(cat => ({
7784
8181
  title: cat.title,
7785
8182
  items: cat.items.map(item => {
7786
- const testId = typeof item.text === 'string' ? resolveTestId(item.text) : undefined;
7787
- return adaptInfoItem(item, testId);
8183
+ const text = plainText(item.text);
8184
+ const testId = text !== undefined ? resolveTestId(text) : undefined;
8185
+ const fallbackCode = text !== undefined ? resolveFallbackCode(text) : undefined;
8186
+ return adaptInfoItem(item, testId, t, fallbackCode);
7788
8187
  })
7789
8188
  }));
7790
8189
  return {
@@ -8668,20 +9067,43 @@ const buildPublishedCertificateSections = (cert, pem, t, now, assertionMarkType)
8668
9067
 
8669
9068
  // --- Verification issues ---
8670
9069
  // Sourced from PEM evidence errors plus an ambiguous leaf-selection signal.
8671
- 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);
9070
+ //
9071
+ // These rows are built here rather than in the extractor adapter, so the adapter's
9072
+ // glossary-link pass (DS-113) does not reach them. Append the link locally, keyed on
9073
+ // the same code the adapter uses, so a verification issue is explained exactly as the
9074
+ // equivalent row in the legacy VMC section is.
9075
+ const withGlossaryLink = (text, code) => {
9076
+ const href = resolveBimiGlossaryHref(code);
9077
+ return href ? `${text} [${t('card.bimi.glossary-link')}](${href})` : text;
9078
+ };
9079
+
9080
+ // pem.errors entries carry a code alongside the message (IPForensics BimiError);
9081
+ // keep it so the row can resolve its page rather than dropping to message-only.
9082
+ 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' ? {
9083
+ message: e,
9084
+ code: undefined
9085
+ } : {
9086
+ message: e === null || e === void 0 ? void 0 : e.message,
9087
+ code: e === null || e === void 0 ? void 0 : e.code
9088
+ }).filter(e => !!e.message);
8672
9089
  const isAmbiguous = (pem === null || pem === void 0 ? void 0 : pem.leafSelectionMethod) === 'AMBIGUOUS';
8673
9090
  const issueItems = [];
8674
9091
  if (isAmbiguous) {
9092
+ const key = 'card.bimi.vmc.cert-ambiguous-leaf_md';
8675
9093
  issueItems.push({
8676
9094
  type: 'warning',
8677
- text: t('card.bimi.vmc.cert-ambiguous-leaf_md'),
9095
+ text: withGlossaryLink(t(key), resolveBimiCodeForTranslationKey(key)),
8678
9096
  'data-testid': 'bimi-cert-issue-ambiguous'
8679
9097
  });
8680
9098
  }
8681
- errorMessages.forEach((message, idx) => {
9099
+ errorEntries.forEach((_ref2, idx) => {
9100
+ let {
9101
+ message,
9102
+ code
9103
+ } = _ref2;
8682
9104
  issueItems.push({
8683
9105
  type: 'warning',
8684
- text: message,
9106
+ text: withGlossaryLink(message, code),
8685
9107
  'data-testid': `bimi-cert-issue-${idx}`
8686
9108
  });
8687
9109
  });