Comparing Web Resources (JavaScript) Across Multiple Dataverse Environments Using a Console App


We recently refactored a large JavaScript solution — replacing a number of deprecated methods with their supported equivalents, along with some performance improvements — the kind of change that touches a lot of files without changing what any of them are actually supposed to do. That solution needed to go out to three Production environments, and before rolling it out, we wanted to be sure of one thing: were all three Prod environments currently running the same JavaScript to begin with? If one of them had quietly drifted from the others over time — a direct hotfix, a missed deployment — we wanted to know that before deploying, not after.

That’s what led us to write this utility. And while our own case was three Prod environments, there’s nothing about it that ties it specifically to Dev/UAT/Prod — that’s just the most common shape for this kind of check. The source is really just “the environment whose solution I want to treat as the baseline,” and the target(s) are “however many environments I want to check that baseline against.” You could just as easily point the source at UAT and compare it against multiple Staging / DM / UAT instances, or point it at Prod and verify a DR/failover environment matches — any number of targets can be listed, and each is compared against the source independently, so you always know exactly which environment(s) are out of step rather than just “something, somewhere, differs.”

To keep this post simple, we’ll walk through a smaller example here — a Dev environment with two solutions that between them contain a handful of JS web resources — and compare it against UAT and Prod to confirm all three environments.

The approach

Rather than matching components by name across environments, we used the solutioncomponent entity to pull the exact web resource GUIDs belonging to our solution from the source environment (Dev). This matters because a component’s GUID stays the same wherever it travels via solution import — so the same GUID can be looked up directly in every other environment, with no name-matching guesswork involved.

For each web resource, in each environment:

  • Retrieve the content field (base64-encoded) and decode it.
  • Compute a SHA-256 hash of the decoded bytes.

If two environments produce the same hash for the same GUID, the files are guaranteed byte-identical. If the hashes differ, something changed — even a single character is enough to produce a completely different hash.

Sample Code –

private const int COMPONENTTYPE_WEBRESOURCE = 61;
private const int WEBRESOURCETYPE_JSCRIPT = 3;

private static List<Guid> GetJavaScriptWebResourceIds(IOrganizationService svc, List<string> solutionUniqueNames)
{
    var solutionQuery = new QueryExpression("solution")
    {
        ColumnSet = new ColumnSet("solutionid")
    };
    solutionQuery.Criteria.AddCondition("uniquename", ConditionOperator.In,
        solutionUniqueNames.Cast<object>().ToArray());
    var solutionIds = svc.RetrieveMultiple(solutionQuery).Entities
        .Select(s => s.Id).Cast<object>().ToArray();

    var compQuery = new QueryExpression("solutioncomponent")
    {
        ColumnSet = new ColumnSet("objectid")
    };
    compQuery.Criteria.AddCondition("solutionid", ConditionOperator.In, solutionIds);
    compQuery.Criteria.AddCondition("componenttype", ConditionOperator.Equal, COMPONENTTYPE_WEBRESOURCE);

    var allWebResourceIds = svc.RetrieveMultiple(compQuery).Entities
        .Select(e => (Guid)e["objectid"])
        .Distinct()
        .ToArray();

    // The Web Resource component type covers every kind of web resource in the solution -
    // JS, HTML, CSS, PNG, SVG, and so on - not JavaScript specifically. We only want the
    // actual scripts, so we filter again on the webresource entity's own type field.
    var wrQuery = new QueryExpression("webresource")
    {
        ColumnSet = new ColumnSet("name")
    };
    wrQuery.Criteria.AddCondition("webresourceid", ConditionOperator.In, allWebResourceIds.Cast<object>().ToArray());
    wrQuery.Criteria.AddCondition("webresourcetype", ConditionOperator.Equal, WEBRESOURCETYPE_JSCRIPT);

    return svc.RetrieveMultiple(wrQuery).Entities
        .Select(e => e.Id)
        .ToList();
}

private static string GetContentHash(IOrganizationService svc, Guid webResourceId)
{
    var e = svc.Retrieve("webresource", webResourceId, new ColumnSet("content"));
    var bytes = Convert.FromBase64String((string)e["content"]);

    using (var sha = SHA256.Create())
    {
        var hash = sha.ComputeHash(bytes);
        return BitConverter.ToString(hash).Replace("-", "");
    }
}

Running this against Dev + UAT + Prod, for every JavaScript web resource GUID found, gives a simple report per environment: MATCH, MISMATCH, or MISSING.

Going a step further — showing what actually changed

A MISMATCH on its own only tells you that something differs, not what. So we extended it to also diff the two files line by line whenever the hashes didn’t match, using a classic LCS (Longest Common Subsequence) based diff — the same underlying idea git diff uses.

private static List<string> DiffTextLines(string baseline, string other, int maxDiffs = 6)
{
    var a = baseline.Replace("\r\n", "\n").Split('\n');
    var b = other.Replace("\r\n", "\n").Split('\n');

    int n = a.Length, m = b.Length;
    var dp = new int[n + 1, m + 1];
    for (int i = n - 1; i >= 0; i--)
        for (int j = m - 1; j >= 0; j--)
            dp[i, j] = a[i] == b[j] ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]);

    var diffs = new List<string>();
    int x = 0, y = 0;
    while (x < n && y < m && diffs.Count < maxDiffs)
    {
        if (a[x] == b[y]) { x++; y++; continue; }
        if (dp[x + 1, y] >= dp[x, y + 1]) { diffs.Add($"line {x + 1} removed: \"{a[x]}\""); x++; }
        else { diffs.Add($"line {y + 1} added: \"{b[y]}\""); y++; }
    }
    return diffs;
}

This walks a dynamic-programming grid to find the longest sequence of lines common to both files. Everything outside that common sequence is, by definition, either a removed line or an added one — which is exactly what shows up in the report instead of a plain “files differ”.

Every result also gets written out to a CSV, one row per web resource, with a hash column per environment, a Status column, and a DiffSummary column showing the actual line-level differences for anything that doesn’t match.

How it works, end to end

  1. Connects to the source environment (Dev) and reads solutioncomponent for the solution’s unique name, filtered to web resources only.
  2. For each web resource GUID found, fetches that same GUID from every target environment listed (UAT, Prod, or as many as you configure).
  3. Hashes the content from each environment and compares every target’s hash against the source’s hash — independently, so you know exactly which target(s) differ, not just that “something” differs.
  4. For anything that doesn’t match, runs the LCS diff and records the specific lines that changed.
  5. Writes a CSV report and saves every environment’s actual file content to disk, so any mismatch can be opened directly in a diff tool if the summary line isn’t enough.

Using it yourself

The console app is config-driven — no code changes needed to point it at your own solution and environments. config.json looks like this:

{
  "SolutionNames": ["YourSolutionUniqueName1", "YourSolutionUniqueName2"],
  "Source": {
    "Label": "Dev",
    "ConnectionString": "AuthType=ClientSecret;Url=https://yourorg-dev.crm.dynamics.com;ClientId=..;ClientSecret=..;"
  },
  "Targets": [
    { "Label": "UAT",  "ConnectionString": "AuthType=ClientSecret;Url=https://yourorg-uat.crm.dynamics.com;ClientId=..;ClientSecret=..;" },
    { "Label": "Prod", "ConnectionString": "AuthType=ClientSecret;Url=https://yourorg-prod.crm.dynamics.com;ClientId=..;ClientSecret=..;" }
  ],
  "OutputFolder": "output"
}
  • SolutionNames — the unique name(s) of the solution(s) holding your web resources. You can list more than one, as in the example above, if your JS is spread across a couple of solutions.
  • Source — the environment you trust as the baseline (usually Dev).
  • Targets — as many environments as you want to check, each compared independently against Source.

Once config.json is filled in, just run:

jscompare-fx.exe config.json

and the report and diffed files show up under the configured output folder.

https://github.com/nishantranacrm/JsCompareFx

Hope it helps..

Advertisements

Power Platform Environment Not Showing Up in Maker Portal & Power Platform Admin Center (Dataverse / Dynamics 365)


Recently, we ran into an interesting issue while working with a Dynamics 365 / Dataverse environment. Even though the user was assigned the System Administrator security role, the environment did not appear in either the Power Platform Admin Center or the Maker Portal. The surprising part was that nothing was actually wrong with the permissions. The environment eventually appeared automatically after about 4 to 6 hours, which suggests there was a synchronization delay. Another interesting aspect was that, out of 3 environments assigned to the user, only 1 had this problem.

The problem

After receiving System Administrator access, we expected the environment to appear immediately in:

• Power Platform Admin Center (PPAC)
• Power Apps Maker Portal

However, the environment simply wasn’t listed, even though we could access the Dynamics 365 application directly using its URL.

Open the Maker experience from Advanced Settings

If you already have the Dynamics 365 / CRM URL for the environment, you can continue working without waiting for the synchronization.

Navigate to:

Advanced Settings → Settings → Customize the System

Then select the Try New Experience option.

Selecting Try New Experience opens the modern Maker experience for that environment even when it isn’t listed in the environment selector.

Open the environment directly in PPAC or Maker Portal using the Environment ID

We can also open the environment directly in Power Platform Admin Center or Maket Portal by using the Environment ID.

Step 1: Get the Environment ID

If you have a Maker Portal URL like:

https://make.powerapps.com/environments/50d07577-70d1-4846-b1d1-af1b52c7685f/

 Copy the Environment ID:

50d07577-70d1-4846-b1d1-af1b52c7685f

Step 2: Use the Environment ID in PPAC

Replace the Environment ID in the PPAC URL:

https://admin.powerplatform.microsoft.com/manage/environments/environment/50d07577-70d1-4846-b1d1-af1b52c7685f/hub?geo=Oce

The Environment ID can also be found in the environment details page. We can use the Environment ID and the corresponding Maker and PPAC URL to open the particular environment.

Reference –

Troubleshoot missing environments

Also check –

https://www.michaelroth42.com/post/2022-08-26-power-platform-security-levels

Hope it helps..

Advertisements

SQL4CDS UTC Mode: Converting Advanced Find Date Filters to UTC Boundaries– A Quick Reference (Dataverse / Dynamics 365)


While validating record counts for a Work Orders view in Dynamics 365 Field Service, we ran into the same underlying issue covered in an earlier post – Why SQL4CDS Record Counts May Not Match Advanced Find for Date Filters.

This time, instead of just explaining the root cause again, we put together a quick reference table for converting an Advanced Find date range into the correct SQL4CDS UTC boundary, for any user time zone.

The Scenario

Advanced Find query on Work Orders, filtered on Created On:

  • On or After 01/01/2026
  • On or Before 05/01/2026

The user running this is in Auckland, New Zealand. (User’s Time Zone is Auckland)

We were running SQL4CDS in UTC mode. A query that simply matches the literal date strings against createdon will not reliably reproduce the Advanced Find count, because Advanced Find evaluates the date range in the user’s local time zone, while createdon is stored in UTC. The two only line up once the date range is converted to explicit UTC boundaries.

The Reliable Formula

StartBoundaryUTC = StartDate 00:00:00 (user’s local time) → converted to UTC

EndBoundaryUTC   = (EndDate + 1 day) 00:00:00 (user’s local time) → converted to UTC

WHERE createdon >= StartBoundaryUTC AND createdon < EndBoundaryUTC

Two points worth calling out:

  • The upper boundary always uses End Date + 1 day, with a strict <, not <=. This avoids any ambiguity around milliseconds and reliably captures the entire end date.
  • For time zones ahead of UTC (New Zealand, India), convert by subtracting the offset. For time zones behind UTC (Hawaii, US), convert by adding the offset.

Quick Reference Table

Boundary used below: 1/01/2026 to 5/01/2026 (end boundary = 6/01/2026 local, converted to UTC).

Time ZoneOffsetDST Active?Start CalculationEnd CalculationSQL4CDS Boundary
UTC+0:00No DST2026-01-01T00:00 − 0:002026-01-06T00:00 − 0:00>= ‘2026-01-01T00:00:00Z’ AND < ‘2026-01-06T00:00:00Z’
India (IST)+5:30No DST2026-01-01T00:00 − 5:302026-01-06T00:00 − 5:30>= ‘2025-12-31T18:30:00Z’ AND < ‘2026-01-05T18:30:00Z’
New Zealand (NZDT – summer)+13:00Yes (active in Jan)2026-01-01T00:00 − 13:002026-01-06T00:00 − 13:00>= ‘2025-12-31T11:00:00Z’ AND < ‘2026-01-05T11:00:00Z’
New Zealand (NZST – winter)+12:00Yes (inactive in Jan)2026-01-01T00:00 − 12:002026-01-06T00:00 − 12:00>= ‘2025-12-31T12:00:00Z’ AND < ‘2026-01-05T12:00:00Z’
Hawaii (HST)−10:00No DST2026-01-01T00:00 + 10:002026-01-06T00:00 + 10:00>= ‘2026-01-01T10:00:00Z’ AND < ‘2026-01-06T10:00:00Z’

Since January falls in NZ summer, the Auckland user’s boundary above uses NZDT (+13:00):

SELECT count(1)
FROM msdyn_workorder
WHERE createdon >= '2025-12-31T11:00:00Z'
AND createdon < '2026-01-05T11:00:00Z'

This reproduces the Advanced Find count for the same range.

NZ DST Transition Windows

New Zealand does not stay on a single offset year-round, so the correct value depends on the date range being queried, not the date the query is run.

PeriodOffset
Late Sep – early Apr (NZDT)UTC+13
Early Apr – late Sep (NZST)UTC+12

Confirm the exact transition dates for the specific year, as they shift slightly.

Rules of Thumb

RuleReason
End boundary = End Date + 1 day, use < not <=Captures the full end date without truncating time
Time zones ahead of UTC: subtract the offsetUTC = Local − Offset
Time zones behind UTC: add the offsetUTC = Local + Offset
Check DST for the query dates, not today’s dateThe same time zone can have two different offsets depending on the time of year

Key Takeaway

When running SQL4CDS in UTC mode, the reliable and repeatable approach is to convert the Advanced Find date range into explicit UTC boundaries using the local-time offset (accounting for DST where applicable), and query using >= / < against those boundaries.

Reference

For more background on how SQL4CDS interprets date and time values in UTC vs Local mode, see Mark Carrington’s article: Date/Time handling in SQL 4 CDS

Hope it helps..

Advertisements

When Entity.Id Is Guid.Empty with QueryExpression.Distinct = True (Dataverse)


While optimising the performance of a Dataverse plugin, we noticed a QueryExpression using ColumnSet(true). The business logic only required a few attributes, so replacing ColumnSet(true) with a minimal ColumnSet looked like an easy performance improvement. The query also used Distinct = true, which we left unchanged because it had always been there and everything was working correctly.

The Original Query

query.ColumnSet = new ColumnSet(true);
query.Distinct = true;

We changed the query to retrieve only the attributes required by the business logic:

query.ColumnSet = new ColumnSet(
    "msdyn_systemstatus",
    "msdyn_datewindowstart",
    "custom_cancelledreason");
query.Distinct = true;

The optimisation looked perfectly valid. The query returned the expected records, but part of the plugin logic suddenly stopped working.

The Unexpected Bug

The plugin compared Work Orders using Entity.Id to determine whether a record already existed in a collection. During debugging, we discovered that every retrieved entity had Guid.Empty as its Id, causing the comparison logic to fail and duplicate records to be added.

Finding the Root Cause

To isolate the problem, we reproduced the behaviour with a simple Lead query.

QueryExpression query = new QueryExpression("lead");
query.ColumnSet = new ColumnSet("lastname");
query.Distinct = true;

Once again, the record was returned successfully, but Entity.Id was Guid.Empty.

While reading the Microsoft documentation for QueryExpression.Distinct, we found the following remark:

“When the Distinct property is true, the results returned don’t include primary key values for each record because they represent an aggregation of all the distinct values.”

The Fix

Including the primary key in the ColumnSet resolved the issue.

query.ColumnSet = new ColumnSet("leadid", "lastname");
query.Distinct = true;

After this change, both Entity.Id and the leadid attribute were populated correctly.

One More Observation

While investigating the issue, we realised something else. The original query used ColumnSet(true) together with Distinct = true. Since ColumnSet(true) retrieves every readable attribute, including the primary key, every record is already unique because the primary key itself is unique. In that particular QueryExpression there were no LinkEntity joins or other scenarios that could naturally produce duplicate rows. That meant Distinct = true was not really providing any value. In fact, once we reviewed the query, removing Distinct = true was a cleaner solution than simply adding the primary key back into the ColumnSet.
This serves as a useful reminder that performance optimisation is not just about reducing the columns retrieved. It is also a good opportunity to question whether every part of the original query is still necessary.

Lessons Learned

• Replacing ColumnSet(true) with a minimal ColumnSet is a good optimisation.
• If a query uses Distinct = true and the code relies on Entity.Id, include the primary key in the ColumnSet.
• Review whether Distinct = true is actually required. In many QueryExpression scenarios, especially those without joins, it may be redundant.
• Small performance improvements can sometimes expose subtle behaviours that are easy to overlook.

Hope it helps..

Advertisements

Implementing Server-Side Honeypot Validation for Dynamics 365 Customer Insights – Journeys Forms


In our previous post, we implemented a simple Honeypot for Dynamics 365 Customer Insights – Journeys Real-Time Marketing forms using JavaScript.

Although that prevents most automated submissions, the validation only runs in the browser. Anyone can bypass the JavaScript and submit requests directly to the server.

Fortunately, Customer Insights – Journeys provides a server-side validation pipeline through the msdynmkt_validateformsubmission message, allowing us to validate every form submission before a Lead, Contact, or custom record is created.

When a form with the data-validate-submission attribute is submitted, Microsoft first executes its built-in validation plugin, followed by any custom plugins registered on the same message.

Update the HTML of the marketing form and add the attribute –


One important behavior to be aware of is that if our form doesn’t contain Microsoft’s built-in CAPTCHA fields, the default Microsoft validation plugin sets IsValid = false, causing the submission to fail. Our custom plugin must overwrite this response by returning IsValid = true when our validation succeeds, or IsValid = false if it fails.
In this post, we’ll use this pipeline to validate the Honeypot field that we created in the previous article.

Previous article:
Implementing a Simple Honeypot in Dynamics 365 Customer Insights – Journeys Real-Time Marketing Forms

Register the Validation Plugin

The submitted form values are available in the msdynmkt_formsubmissionrequest input parameter.

Next, as described in the Microsoft Learn documentation, we can overwrite the response by returning a new ValidateFormSubmissionResponse with IsValid = true.

Read the Honeypot Field

var request = Deserialize<FormSubmissionRequest>(                (string)context.InputParameters["msdynmkt_formsubmissionrequest"]);
 var fields = request?.Fields ?? new List<FormField>();
 string honeypotValue = fields.FirstOrDefault(f => f.Key == HoneypotFieldName)?.Value;

Reject Bot Submissions

if (!string.IsNullOrWhiteSpace(honeypotValue))
{
    SetValidationResponse(context, false, "Form validation failed.");
    return;
}

Return the Validation Response

Customer Insights expects the plugin to return a ValidateFormSubmissionResponse. We also include the Honeypot field in ValidationOnlyFields, so it isn’t mapped to the target record.

On Successful Form submission, we can see our validation plugin triggering

If server side validation fails (i.e. honey pot field has a value in it) we get our custom error message and submission fails.

Complete Plugin

using Microsoft.Xrm.Sdk;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;

namespace DI.D365.Plugins.Marketing
{
    /// <summary>
    /// Plugin Registration
    ///
    /// Message: msdynmkt_validateformsubmission
    /// Stage: PostOperation
    /// Mode: Synchronous    
    /// </summary>
    public class ValidateHoneypot : IPlugin
    {
        private const string HoneypotFieldName = "hp_check";
        private const string ErrorMessage = "Form validation failed.";

        public void Execute(IServiceProvider serviceProvider)
        {
            var context = (IPluginExecutionContext)serviceProvider.GetService(
                typeof(IPluginExecutionContext)
            );

            var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));

            if (!context.InputParameters.Contains("msdynmkt_formsubmissionrequest"))
            {
                tracing.Trace("Form submission request not found.");
                return;
            }

            var request = Deserialize<FormSubmissionRequest>(
                (string)context.InputParameters["msdynmkt_formsubmissionrequest"]
            );

            var fields = request?.Fields ?? new List<FormField>();

            string honeypotValue = fields.FirstOrDefault(f => f.Key == HoneypotFieldName)?.Value;

            tracing.Trace($"Honeypot Value: {honeypotValue}");

            if (!string.IsNullOrWhiteSpace(honeypotValue))
            {
                tracing.Trace("Honeypot validation failed.");

                SetValidationResponse(context, false, ErrorMessage);

                return;
            }

            tracing.Trace("Honeypot validation passed.");

            SetValidationResponse(context, true, null);
        }

        private void SetValidationResponse(
            IPluginExecutionContext context,
            bool isValid,
            string error
        )
        {
            var response = new ValidateFormSubmissionResponse
            {
                IsValid = isValid,
                ValidationOnlyFields = new List<string> { HoneypotFieldName },
                Error = error
            };

            context.OutputParameters["msdynmkt_validationresponse"] = Serialize(response);
        }

        private static T Deserialize<T>(string json)
        {
            using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)))
            {
                return (T)new DataContractJsonSerializer(typeof(T)).ReadObject(stream);
            }
        }

        private static string Serialize<T>(T value)
        {
            using (var stream = new MemoryStream())
            {
                new DataContractJsonSerializer(typeof(T)).WriteObject(stream, value);

                return Encoding.UTF8.GetString(stream.ToArray());
            }
        }
    }

    #region Helper Classes

    [DataContract]
    public class FormSubmissionRequest
    {
        [DataMember(Name = "Fields")]
        public List<FormField> Fields { get; set; }
    }

    [DataContract]
    public class FormField
    {
        [DataMember(Name = "Key")]
        public string Key { get; set; }

        [DataMember(Name = "Value")]
        public string Value { get; set; }
    }

    [DataContract]
    public class ValidateFormSubmissionResponse
    {
        [DataMember(Name = "IsValid")]
        public bool IsValid { get; set; }

        [DataMember(Name = "ValidationOnlyFields")]
        public List<string> ValidationOnlyFields { get; set; }

        [DataMember(Name = "Error")]
        public string Error { get; set; }
    }
    #endregion
}

Conclusion

Adding a client-side Honeypot is a great first step, but validating it on the server makes the solution much more robust. Since every submission passes through the  msdynmkt_validateformsubmission pipeline, bots can’t bypass the validation simply by skipping our JavaScript.

The msdynmkt_validateformsubmission Custom API is implemented by Microsoft’s Microsoft.Dynamics.Cxp.Forms.Plugins.Plugins.ValidateFormSubmissionPlugin, which performs the default Microsoft CAPTCHA validation and initializes the msdynmkt_validationresponse. We can then register our own plugin steps on the same message. Microsoft recommends registering custom validation plugins with an Execution Order of 20, allowing them to execute after the out-of-the-box Microsoft.Dynamics.Cxp.FormsReCaptcha.Plugins.ReCaptchaValidationPlugin (Execution Order 10) and overwrite the validation response if required.”

References

Hope it helps..

Advertisements

Implementing a Simple Honeypot in Dynamics 365 Customer Insights – Journeys Real-time Marketing Forms


While working with Dynamics 365 Customer Insights – Journeys Real-time Marketing Forms, we wanted to explore a simple way of reducing bot submissions.

A common technique used on websites is a honeypot. The idea is straightforward:

  • Add a field that genuine users never see.
  • Hide it using JavaScript.
  • If the field contains a value when the form is submitted, assume it was completed by a bot and cancel the submission.

In this post, we’ll implement this using the supported Client-side Extensibility events provided by Dynamics 365.

Step 1: Add an Unbound Field

Add a new Short Text field to the form.

The field can be Unbound, as it is only used for validation and does not need to be stored in Dataverse.

We used hp_check for the field name as this avoids browser autofill heuristics that may populate well-known fields such as Middle Name, Email, or Phone.

Step 2: Hide the Field

The field should remain in the HTML so that automated bots can still discover it, but it should not be visible to genuine users.

In our implementation, the field is hidden using CSS, which avoids any brief flicker that could occur if it were hidden only after the form loads.

.textFormFieldBlock:has(input[name="hp_check"]) {
    position: absolute !important;
    left: -9999px !important;
    width: 1px !important;
    height: 1px !important;
    overflow: hidden !important;
}

Step 3: Validate During Form Submission

Using the supported d365mkt-afterformload and d365mkt-formsubmit events, configure the field and validate it before submission.

<script>
let honeypotField = null;

document.addEventListener("d365mkt-afterformload", function () {

    honeypotField = document.querySelector('[name="hp_check"]');

    if (!honeypotField) {
        return;
    }

    honeypotField.setAttribute("autocomplete", "off");
    honeypotField.setAttribute("tabindex", "-1");
    honeypotField.setAttribute("aria-hidden", "true");
});

document.addEventListener("d365mkt-formsubmit", function (event) {

    if (honeypotField && honeypotField.value.trim() !== "") {
        event.preventDefault();
    }

});
</script>

The d365mkt-formsubmit event is cancelable, so calling event.preventDefault() prevents the form from being submitted.

Testing

To test the implementation, we opened the browser Developer Tools and executed the following in the Console:

document.querySelector('[name="hp_check"]').value = "I am a bot";

When we clicked Submit, the d365mkt-formsubmit event was triggered and event.preventDefault() successfully cancelled the submission.

Things to consider

While implementing this, there were a few considerations that helped improve the solution:

  • Use a neutral field name such as hp_check instead of generic field name to reduce the chance of browser autofill populating the field.
  • Hide the field using CSS instead of JavaScript to avoid a brief flash of the field while the page is loading.
  • Keep the field unbound, as it is only used for validation.
  • A client-side honeypot helps reduce spam submissions, but it should be considered one layer of protection. For higher security requirements, additional server-side validation can also be implemented. For e.g. if it is bound field on lead than a plugin on pre create operation or if it is unbound we can have plugin against Form Submission table.

Conclusion

A honeypot is a simple but effective technique for reducing automated submissions to Dynamics 365 Customer Insights – Journeys Real-time Marketing Forms.

By using the supported Client-side Extensibility events, the implementation remains lightweight, requires no Dataverse customizations, and can be easily reused across multiple for

References

Real-time Marketing Form Client-side Extensibility

Hope it helps..

Advertisements