@mindful-web/marko-web-omeda-identity-x 1.85.5 → 1.87.0

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.
package/README.md CHANGED
@@ -18,7 +18,7 @@ All configuration data must be passed to the middleware when loaded (See [Middle
18
18
  | `clientKey` | No | The Omeda client key (such as `client_orgc`.) *Required if sending deployment optIns via the underlying omeda package!* [marko-web-omeda docs](../marko-web-omeda)
19
19
  | `appId` | **Yes** | The Omeda application API read token
20
20
  | `inputId` | **Yes** | The Omeda application API write token
21
- | `rapidIdentProductId` | **Yes** | The Omeda identifier for a Website product (ProductType=7).
21
+ | `rapidIdentProductId` | No — **deprecated**, see [Website products](#website-products-do-not-use-rapididentproductid) | The Omeda identifier for a Website product (ProductType=7). Sends the product on **every** rapid identification, which cuts a duplicate Omeda order each time. Put the website product in the site's deduped opt-in hook instead. | _none_
22
22
  | `idxConfig` | **Yes** | An instance of the IdentityX configuration class (see [marko-web-identity-x#1](../marko-web-identity-x/config.js)) | _n/a_
23
23
  | `idxRouteTemplates` | **Yes** | An object containing the Marko templates to use for each IdentityX endpoint. (see [marko-web-identity-x#2](../marko-web-identity-x/index.js))
24
24
  | `omedaPromoCodeCookieName` | No | The name of the cookie to look for a persisted/original promo code. | `omeda_promo_code` |
@@ -38,6 +38,67 @@ All configuration data must be passed to the middleware when loaded (See [Middle
38
38
  | `appendPromoCodeToHook[].hook` | | The name of the hook, such as `onLoginLinkSent`
39
39
  | `appendPromoCodeToHook[].promoCode` || The Omeda Promo Code (`String`) to append.
40
40
 
41
+ ### Website products: do not use `rapidIdentProductId`
42
+
43
+ `rapidIdentProductId` reads like site *identification* metadata, distinct from subscriptions. It
44
+ isn't. It is forwarded as `productId` to the rapid identification call, and in the omeda GraphQL
45
+ `rapidCustomerIdentification` resolver it is merged with `input.subscriptions` into a single
46
+ `Products` array on Save Customer and Order:
47
+
48
+ ```js
49
+ const productMap = new Map([...(input.productId ? [[input.productId, true]] : [])]);
50
+ // …
51
+ subscriptions.forEach(({ id, receive }) => {
52
+ subscriptionMap.set(id, receive);
53
+ if (!productMap.has(id)) productMap.set(id, receive);
54
+ });
55
+ // …
56
+ Products: [...productMap].map(([OmedaProductId, Receive]) => ({ … }))
57
+ ```
58
+
59
+ Omeda cuts an order for whatever lands in `Products`. So `rapidIdentProductId` orders the website
60
+ product on **every** identification — every login-link send, auth success and profile update — which
61
+ is a duplicate order per login, not a one-time attribution.
62
+
63
+ **Do this instead.** Leave `rapidIdentProductId` unset and list the website product in the site's
64
+ `identityXOptInHooks.onLoginLinkSent.productIds`. The site's `onLoginLinkSentFormatter` (in each
65
+ repo's `packages/global/config/omeda-identity-x.js`) filters those ids through this package's
66
+ [`getOmedaSubscriptionIds`](./omeda-data/get-omeda-subscription-ids.js) before building
67
+ `payload.appendSubscriptions`, so the product is ordered once — when the customer doesn't already
68
+ hold it — and every later identification is a no-op:
69
+
70
+ ```js
71
+ // sites/<site>/config/identity-x-opt-in-hooks.js
72
+ module.exports = {
73
+ onLoginLinkSent: {
74
+ productIds: [
75
+ 15375, // <Site> Website — deduped against the live Omeda record; ordered only when missing
76
+ ],
77
+ },
78
+ };
79
+ ```
80
+
81
+ ```js
82
+ // sites/<site>/config/omeda-identity-x.js — no `rapidIdentProductId`
83
+ module.exports = configure({ omedaConfig, idxConfig, websiteBehaviorAttributeId, omedaPromoCodePrefix });
84
+ ```
85
+
86
+ The website product still belongs on the customer's record — it just needs to get there once. The
87
+ field is retained for backwards compatibility and is optional in the Joi schema; it should not be
88
+ set on new sites.
89
+
90
+ History: the duplicate orders were found and fixed on allured (allured-business-media-websites#395,
91
+ Oct 2025) and the placement was rolled out fleet-wide through Aug 2026 (ab-media-websites#539,
92
+ ironmarkets-websites#993, cox-matthews-associates-websites#534, fusable-websites#1040,
93
+ watt-global-media-websites#731, industrial-media-websites#623). This README previously listed the
94
+ field as **required** and did not document the hook at all, which is what led two of those PRs to
95
+ initially move the ids the wrong way.
96
+
97
+ A related trap when reading site configs: some repos' `onAuthenticationSuccess.productIds` hold
98
+ **deployment type** ids despite the key name, because that formatter matches
99
+ `product.deploymentTypeId` and emits `deploymentTypes`. Check which formatter consumes a list before
100
+ assuming its ids are products.
101
+
41
102
  ### Customer re-sync interval
42
103
 
43
104
  `userResyncIntervalMs` is read from the **IdentityX** config (`idxConfig`), not from the properties
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-omeda-identity-x$1.85.5/components/identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-omeda-identity-x$1.87.0/components/identify.marko",
6
6
  marko_component = require("./identify.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_getCookieId = require("@mindful-web/marko-web-omeda-identity-x/utils/get-cookie-id"),
@@ -51,7 +51,7 @@ marko_template._ = marko_renderer(render, {
51
51
  }, marko_component);
52
52
 
53
53
  marko_template.meta = {
54
- id: "/@mindful-web/marko-web-omeda-identity-x$1.85.5/components/identify.marko",
54
+ id: "/@mindful-web/marko-web-omeda-identity-x$1.87.0/components/identify.marko",
55
55
  component: "./identify.marko",
56
56
  tags: [
57
57
  "@mindful-web/marko-web-identity-x/components/identify.marko",
package/index.js CHANGED
@@ -45,7 +45,11 @@ const schemas = require('./validation/schemas');
45
45
  * @prop {?Promise<object>} onLoginLinkSentFormatter
46
46
  * @prop {?Promise<object>} onAuthenticationSuccessFormatter
47
47
  * @prop {?Promise<object>} onUserProfileUpdateFormatter
48
- * @prop {number} rapidIdentProductId
48
+ * @prop {?number} rapidIdentProductId **Deprecated.** Sends this website product on _every_ rapid
49
+ * identification, which cuts a duplicate Omeda order each time — the resolver merges it into the
50
+ * same `Products` array as `subscriptions`. Leave unset and put the website product in the site's
51
+ * `identityXOptInHooks.onLoginLinkSent.productIds`, which is deduped against the live customer
52
+ * record via `getOmedaSubscriptionIds`. Retained for backwards compatibility only; see README.
49
53
  *
50
54
  * @typedef BehaviorSchema
51
55
  * @prop {number} logIn
@@ -194,6 +198,17 @@ module.exports = (app, params = {}) => {
194
198
  rapidIdentProductId: Joi.number(),
195
199
  }), params);
196
200
 
201
+ // `rapidIdentProductId` is deprecated: the omeda `rapidCustomerIdentification` resolver merges it
202
+ // into the same SCAO `Products` array as `subscriptions`, so setting it orders the website
203
+ // product on every identification rather than once. Deliberately `console.warn` over the `warn`
204
+ // util, matching `resolveDurationMS`: this needs to be visible in production, which is where a
205
+ // reintroduced value would quietly start cutting duplicate orders again. No fleet repo sets it as
206
+ // of Aug 2026, so this should never fire outside of a regression.
207
+ if (rapidIdentProductId != null) {
208
+ // eslint-disable-next-line no-console
209
+ console.warn(`[config] rapidIdentProductId (${rapidIdentProductId}) is deprecated for brand ${brandKey}: it orders this website product on EVERY rapid identification, creating duplicate Omeda orders. Move it to the site's identityXOptInHooks.onLoginLinkSent.productIds, which is deduped against the live customer record. See @mindful-web/marko-web-omeda-identity-x README.`);
210
+ }
211
+
197
212
  // strip `oly_enc_id` when identity-x user is logged-in
198
213
  app.use(stripOlyticsParam());
199
214
 
@@ -2,6 +2,7 @@ const Joi = require('@parameter1/joi');
2
2
  const { validate } = require('@parameter1/joi/utils');
3
3
  const olyticsCookie = require('@mindful-web/marko-web-omeda/olytics/customer-cookie');
4
4
  const extractPromoCode = require('../utils/extract-promo-code');
5
+ const getCreateFieldIds = require('../utils/get-create-field-ids');
5
6
  const findEncryptedId = require('../external-id/find-encrypted-customer-id');
6
7
  const props = require('../validation/props');
7
8
  const schemas = require('../validation/schemas');
@@ -25,6 +26,7 @@ module.exports = async (params = {}) => {
25
26
  promoCode: hookDataPromoCode,
26
27
  res,
27
28
  req,
29
+ service,
28
30
  user,
29
31
  } = validate(Joi.object({
30
32
  appendBehaviors: Joi.array().items(schemas.appendBehavior).default([]),
@@ -33,6 +35,7 @@ module.exports = async (params = {}) => {
33
35
  behavior: schemas.behavior.required(),
34
36
  brandKey: props.brandKey.required(),
35
37
  formatter: Joi.function().required(),
38
+ service: Joi.object().required(),
36
39
  user: Joi.object().required(),
37
40
  res: Joi.object().required(),
38
41
  req: Joi.object().required(),
@@ -60,5 +63,13 @@ module.exports = async (params = {}) => {
60
63
  appendPromoCodes,
61
64
  },
62
65
  });
63
- await idxOmedaRapidIdentify(payload);
66
+ await idxOmedaRapidIdentify({
67
+ ...payload,
68
+ // Authenticating is not a general preference change: only answers for fields the
69
+ // registration/login form actually presents may become Omeda writes (this is where a
70
+ // brand-new user's registration checkbox choices land, post-verification). Everything else on
71
+ // the stored user — most dangerously idx-compat's channel-mirror booleans — is state, not a
72
+ // statement. Applied outside the formatter so no site-level formatter can drop it.
73
+ restrictAnswersToFieldIds: getCreateFieldIds(service.config),
74
+ });
64
75
  };
@@ -2,6 +2,7 @@ const Joi = require('@parameter1/joi');
2
2
  const { validate } = require('@parameter1/joi/utils');
3
3
  const generateRequiredFieldPayload = require('@mindful-web/marko-web-identity-x/utils/generate-required-field-payload');
4
4
  const extractPromoCode = require('../utils/extract-promo-code');
5
+ const getCreateFieldIds = require('../utils/get-create-field-ids');
5
6
  const schemas = require('../validation/schemas');
6
7
  const {
7
8
  getAnsweredQuestionMap,
@@ -66,23 +67,30 @@ module.exports = async (params = {}) => {
66
67
 
67
68
  const [omedaLinkedFields, { encryptedCustomerId }] = await Promise.all([
68
69
  getOmedaLinkedFields({ identityX, brandKey }),
69
- idxOmedaRapidIdentify(await formatter({
70
- ...params, // Pass all params through to the formatter
71
- req,
72
- source,
73
- payload: {
74
- user: user.verified ? user : {
75
- ...(requiredFieldKeys.reduce((obj, key) => ({ ...obj, [key]: user[key] }), {})),
76
- id: user.id,
77
- email: user.email,
70
+ idxOmedaRapidIdentify({
71
+ ...(await formatter({
72
+ ...params, // Pass all params through to the formatter
73
+ req,
74
+ source,
75
+ payload: {
76
+ user: user.verified ? user : {
77
+ ...(requiredFieldKeys.reduce((obj, key) => ({ ...obj, [key]: user[key] }), {})),
78
+ id: user.id,
79
+ email: user.email,
80
+ },
81
+ behavior,
82
+ promoCode,
83
+ appendBehaviors,
84
+ appendDemographics,
85
+ appendPromoCodes,
78
86
  },
79
- behavior,
80
- promoCode,
81
- appendBehaviors,
82
- appendDemographics,
83
- appendPromoCodes,
84
- },
85
- })),
87
+ })),
88
+ // Sending a login link is not a general preference change: only answers for fields the
89
+ // registration/login form actually presents may become Omeda writes. Everything else on the
90
+ // stored user — most dangerously idx-compat's channel-mirror booleans — is state, not a
91
+ // statement. Applied outside the formatter so no site-level formatter can drop it.
92
+ restrictAnswersToFieldIds: getCreateFieldIds(identityX.config),
93
+ }),
86
94
  ]);
87
95
 
88
96
  const answers = getAnsweredQuestionMap(user);
@@ -20,6 +20,7 @@ module.exports = async (params = {}) => {
20
20
  omedaPromoCodeDefault,
21
21
  promoCode: hookDataPromoCode,
22
22
  req,
23
+ submittedFieldIds,
23
24
  user,
24
25
  } = validate(Joi.object({
25
26
  appendBehaviors: Joi.array().items(schemas.appendBehavior).default([]),
@@ -32,6 +33,10 @@ module.exports = async (params = {}) => {
32
33
  omedaPromoCodeDefault: Joi.string(),
33
34
  promoCode: Joi.string(),
34
35
  req: Joi.object().required(),
36
+ // The custom field ids the invoking route actually accepted from the submitted form
37
+ // (profile + progressive-profile routes provide it). Absent = an older/other invoker:
38
+ // no restriction, current behavior.
39
+ submittedFieldIds: Joi.array().items(Joi.string()),
35
40
  user: Joi.object().required(),
36
41
  }).unknown(), params);
37
42
 
@@ -55,5 +60,11 @@ module.exports = async (params = {}) => {
55
60
  },
56
61
  });
57
62
 
58
- return idxOmedaRapidIdentify(payload);
63
+ return idxOmedaRapidIdentify({
64
+ ...payload,
65
+ // A profile-style save syncs only the answers its form submitted; everything else on the
66
+ // stored user is state, not a statement. Applied outside the formatter so no site-level
67
+ // formatter can drop it. Absent submittedFieldIds (an older/other invoker) = unrestricted.
68
+ ...(submittedFieldIds && { restrictAnswersToFieldIds: submittedFieldIds }),
69
+ });
59
70
  };
@@ -20,6 +20,7 @@ module.exports = ({
20
20
  const handler = async ({
21
21
  user,
22
22
  promoCode,
23
+ restrictAnswersToFieldIds,
23
24
  deploymentTypes,
24
25
  appendBehaviors,
25
26
  appendDemographics,
@@ -30,6 +31,7 @@ module.exports = ({
30
31
  brandKey,
31
32
  productId,
32
33
  appUser: user,
34
+ restrictAnswersToFieldIds,
33
35
  promoCode: extractPromoCode({
34
36
  promoCode,
35
37
  omedaPromoCodeCookieName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-omeda-identity-x",
3
- "version": "1.85.5",
3
+ "version": "1.87.0",
4
4
  "description": "Marko Omeda+IdentityX integration tools",
5
5
  "repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-omeda-identity-x",
6
6
  "author": "Josh Worden <josh@parameter1.com>",
@@ -13,7 +13,7 @@
13
13
  "test": "yarn compile --no-clean && yarn lint && mocha --reporter spec"
14
14
  },
15
15
  "dependencies": {
16
- "@mindful-web/marko-web-identity-x": "^1.85.5",
16
+ "@mindful-web/marko-web-identity-x": "^1.87.0",
17
17
  "@mindful-web/marko-web-omeda": "^1.85.0",
18
18
  "@mindful-web/object-path": "^1.83.1",
19
19
  "@mindful-web/utils": "^1.83.1",
@@ -33,5 +33,5 @@
33
33
  "chai": "^4.3.7",
34
34
  "mocha": "^6.2.3"
35
35
  },
36
- "gitHead": "26553d897cb2c39e798031e4e2727a81123b1402"
36
+ "gitHead": "ff4b26653f19375680d94b56997484a7dab24f05"
37
37
  }
package/rapid-identify.js CHANGED
@@ -1,8 +1,12 @@
1
1
  const gql = require('graphql-tag');
2
+ const Joi = require('@parameter1/joi');
3
+ const { validate } = require('@parameter1/joi/utils');
2
4
  const { get, getAsArray } = require('@mindful-web/object-path');
3
5
  const isOmedaDemographicId = require('./external-id/is-demographic-id');
4
6
  const isDeploymentTypeId = require('./external-id/is-deployment-type-id');
5
7
  const isProductId = require('./external-id/is-product-id');
8
+ const props = require('./validation/props');
9
+ const schemas = require('./validation/schemas');
6
10
 
7
11
  const ALPHA3_CODE = gql`
8
12
  query GetAlpha3Code($alpha2: String!) {
@@ -26,28 +30,60 @@ const getAlpha3CodeFor = async (alpha2, identityX) => {
26
30
  * @param {object} params.appUser The IdentityX user
27
31
  * @param {string} [params.promoCode] An optional code to send to Omeda for tracking the
28
32
  * acquisition source
33
+ * @param {Array<string|number>} [params.restrictAnswersToFieldIds] When provided, custom field
34
+ * answers (select, text, boolean) are mapped into the Omeda
35
+ * payload ONLY for fields whose id is in this set — i.e. the
36
+ * fields the current interaction's form actually presents.
37
+ * Set on the login paths (link-sent, authentication) to the
38
+ * site's create-field rows: idx-compat surfaces every
39
+ * email/magazine channel as an always-answered boolean whose
40
+ * "answer" mirrors stored subscription state, so pushing the
41
+ * full stored user unsubscribed Omeda customers on every login
42
+ * (the 2026-08 incident). Registration-form fields still flow
43
+ * with their real values — including an explicit `false`
44
+ * checkbox. Contact fields, behaviors, and append* hook data
45
+ * are never restricted. Omit (or pass null) for
46
+ * form-submitting callers (profile), whose payload is already
47
+ * scoped by their route.
29
48
  * @param {IdentityX} params.identityX The Marko web IdentityX service
30
49
  * @param {function} params.omedaRapidIdentify The Omeda rapid identifcation action
31
50
  */
32
- module.exports = async ({
33
- brandKey,
34
- productId,
35
- appUser,
36
-
37
- promoCode,
38
-
39
- deploymentTypes = [],
40
-
41
- appendBehaviors,
42
- appendDemographics,
43
- appendPromoCodes,
44
- appendSubscriptions,
45
-
46
- behavior,
47
-
48
- identityX,
49
- omedaRapidIdentify,
50
- } = {}) => {
51
+ module.exports = async (params = {}) => {
52
+ // The payload contract is enforced here — the single choke point every rapid identification
53
+ // flows through — so an inconsistent payload (a mistyped key, a Set where an array belongs, a
54
+ // malformed append entry) fails loudly instead of silently widening or narrowing what gets
55
+ // written to Omeda. Unknown keys are rejected: the middleware handler passes an explicit key
56
+ // list, so anything else arriving here is a wiring bug.
57
+ const {
58
+ brandKey,
59
+ productId,
60
+ appUser,
61
+ promoCode,
62
+ restrictAnswersToFieldIds,
63
+ deploymentTypes,
64
+ appendBehaviors,
65
+ appendDemographics,
66
+ appendPromoCodes,
67
+ appendSubscriptions,
68
+ behavior,
69
+ identityX,
70
+ omedaRapidIdentify,
71
+ } = validate(Joi.object({
72
+ brandKey: props.brandKey.required(),
73
+ productId: Joi.number(),
74
+ appUser: Joi.object({ email: Joi.string().required() }).unknown(true).required(),
75
+ promoCode: Joi.string().allow('', null),
76
+ restrictAnswersToFieldIds: Joi.array()
77
+ .items(Joi.alternatives().try(Joi.string(), Joi.number())).allow(null),
78
+ deploymentTypes: Joi.array().items(schemas.deploymentType).default([]),
79
+ appendBehaviors: Joi.array().items(schemas.appendBehavior),
80
+ appendDemographics: Joi.array().items(schemas.appendDemographic),
81
+ appendPromoCodes: Joi.array().items(schemas.appendPromoCode),
82
+ appendSubscriptions: Joi.array().items(schemas.appendSubscription),
83
+ behavior: schemas.behavior,
84
+ identityX: Joi.object().required(),
85
+ omedaRapidIdentify: Joi.function().required(),
86
+ }), params);
51
87
  const {
52
88
  givenName,
53
89
  familyName,
@@ -70,9 +106,18 @@ module.exports = async ({
70
106
  }
71
107
 
72
108
  const behaviors = (behavior) ? [{ id: behavior.id, attributes: getAsArray(behavior, 'attributes') }] : [];
109
+
110
+ // Fields the current interaction's form presents. Answers for anything else are stored state,
111
+ // not something the user just said — never rebroadcast them as writes.
112
+ const allowedFieldIds = restrictAnswersToFieldIds == null
113
+ ? null
114
+ : new Set([...restrictAnswersToFieldIds].map((id) => `${id}`));
115
+ const isFieldAllowed = (field) => !allowedFieldIds || allowedFieldIds.has(`${field.id}`);
116
+
73
117
  const demographics = getAsArray(appUser, 'customSelectFieldAnswers').filter((select) => {
74
118
  const { field, hasAnswered } = select;
75
119
  if (!field.active || !field.externalId || !hasAnswered) return false;
120
+ if (!isFieldAllowed(field)) return false;
76
121
  return isOmedaDemographicId({ externalId: field.externalId, brandKey })
77
122
  && select.answers.some((answer) => answer.externalIdentifier);
78
123
  }).map((select) => {
@@ -90,6 +135,7 @@ module.exports = async ({
90
135
  const { field, hasAnswered } = text;
91
136
  const { externalId } = field;
92
137
  if (!field.active || !externalId || !hasAnswered) return;
138
+ if (!isFieldAllowed(field)) return;
93
139
 
94
140
  const { identifier } = field.externalId;
95
141
  const id = parseInt(identifier.value, 10);
@@ -103,6 +149,10 @@ module.exports = async ({
103
149
  const { field, hasAnswered } = boolean;
104
150
  const { externalId } = field;
105
151
  if (!field.active || !externalId || !hasAnswered) return;
152
+ // Channel-mirror booleans (idx-compat surfaces every email/magazine channel as an
153
+ // always-answered boolean of stored subscription state) are never form fields, so on scoped
154
+ // calls they can never become deployment opt-outs or `Receive: 0` subscription writes.
155
+ if (!isFieldAllowed(field)) return;
106
156
 
107
157
  const { identifier } = field.externalId;
108
158
  const id = parseInt(identifier.value, 10);
@@ -0,0 +1,82 @@
1
+ const { describe, it } = require('mocha');
2
+ const { expect } = require('chai');
3
+ const onAuthenticationSuccess = require('../integration-hooks/on-authentication-success');
4
+ const onUserProfileUpdate = require('../integration-hooks/on-user-profile-update');
5
+
6
+ const BRAND = 'athlcd';
7
+
8
+ const encryptedExternalId = () => ({
9
+ identifier: { type: 'encrypted', value: '0240G4865912F6U' },
10
+ namespace: { provider: 'omeda', tenant: BRAND, type: 'customer' },
11
+ });
12
+
13
+ const res = () => ({
14
+ cookie: () => {},
15
+ req: { cookies: {}, hostname: 'www.example.com', get: () => 'www.example.com' },
16
+ });
17
+
18
+ // The registration form presents one custom boolean (a watt-style login checkbox) and one
19
+ // custom select; built-ins never contribute to the answer scope.
20
+ const service = () => ({
21
+ config: {
22
+ getRequiredCreateFieldRows: () => [
23
+ [{ type: 'custom-boolean', id: 'form-newsletter-optin' }, { type: 'built-in', key: 'givenName', required: true }],
24
+ [{ type: 'custom-select', id: 'form-job-function' }],
25
+ ],
26
+ },
27
+ });
28
+
29
+ describe('integration-hooks/on-authentication-success', () => {
30
+ it('scopes answers to the create-form field ids, outside the formatter', async () => {
31
+ let captured;
32
+ await onAuthenticationSuccess({
33
+ behavior: { id: 9 },
34
+ brandKey: BRAND,
35
+ // A formatter that rebuilds the payload from scratch must not be able to drop the scope.
36
+ formatter: async () => ({ user: { email: 'someone@example.com' } }),
37
+ idxOmedaRapidIdentify: async (payload) => { captured = payload; },
38
+ omedaPromoCodeCookieName: 'omeda_promo_code',
39
+ res: res(),
40
+ req: {},
41
+ service: service(),
42
+ user: { verified: true, email: 'someone@example.com', externalIds: [encryptedExternalId()] },
43
+ });
44
+ expect([...captured.restrictAnswersToFieldIds].sort())
45
+ .to.deep.equal(['form-job-function', 'form-newsletter-optin']);
46
+ expect(captured.user).to.deep.equal({ email: 'someone@example.com' });
47
+ });
48
+ });
49
+
50
+ describe('integration-hooks/on-user-profile-update', () => {
51
+ const harness = () => {
52
+ let captured;
53
+ return {
54
+ params: {
55
+ behavior: { id: 10 },
56
+ // A formatter that rebuilds the payload from scratch must not be able to drop the scope.
57
+ formatter: async () => ({ user: { email: 'someone@example.com' } }),
58
+ idxOmedaRapidIdentify: async (payload) => { captured = payload; },
59
+ omedaPromoCodeCookieName: 'omeda_promo_code',
60
+ req: { cookies: {} },
61
+ user: { email: 'someone@example.com' },
62
+ },
63
+ captured: () => captured,
64
+ };
65
+ };
66
+
67
+ it('forwards the route-provided submittedFieldIds as the answer scope, outside the formatter', async () => {
68
+ const h = harness();
69
+ await onUserProfileUpdate({
70
+ ...h.params,
71
+ submittedFieldIds: ['form-job-function', 'chan-ab-magazine'],
72
+ });
73
+ expect(h.captured().restrictAnswersToFieldIds)
74
+ .to.deep.equal(['form-job-function', 'chan-ab-magazine']);
75
+ });
76
+
77
+ it('applies no restriction when the invoker provides none (back-compat)', async () => {
78
+ const h = harness();
79
+ await onUserProfileUpdate(h.params);
80
+ expect(h.captured()).to.not.have.property('restrictAnswersToFieldIds');
81
+ });
82
+ });
@@ -38,7 +38,24 @@ const deploymentBooleanAnswer = ({ id = 'chan-2', deploymentTypeId = 17, answer
38
38
  },
39
39
  });
40
40
 
41
- const run = async (appUser) => {
41
+ const demographicBooleanAnswer = ({ id = 'demo-1', demographicId = 55, answer = false } = {}) => ({
42
+ id,
43
+ hasAnswered: true,
44
+ answer,
45
+ value: answer,
46
+ field: {
47
+ id,
48
+ label: 'Do you purchase?',
49
+ active: true,
50
+ externalId: {
51
+ id: `omeda.demographic.${BRAND}*${demographicId}`,
52
+ namespace: { provider: 'omeda', tenant: BRAND, type: 'demographic' },
53
+ identifier: { value: `${demographicId}`, type: null },
54
+ },
55
+ },
56
+ });
57
+
58
+ const run = async (appUser, opts = {}) => {
42
59
  let captured;
43
60
  await idxOmedaRapidIdentify({
44
61
  brandKey: BRAND,
@@ -46,6 +63,7 @@ const run = async (appUser) => {
46
63
  appUser: { email: 'someone@example.com', ...appUser },
47
64
  identityX: { addExternalUserId: async () => ({}) },
48
65
  omedaRapidIdentify: async (payload) => { captured = payload; return { id: 1, encryptedCustomerId: 'x' }; },
66
+ ...opts,
49
67
  });
50
68
  return captured;
51
69
  };
@@ -79,4 +97,117 @@ describe('rapid-identify (idx → omeda payload)', () => {
79
97
  expect(payload).to.not.have.property('subscriptions');
80
98
  expect(payload).to.not.have.property('deploymentTypes');
81
99
  });
100
+
101
+ describe('with restrictAnswersToFieldIds (form-scoped answers)', () => {
102
+ it('drops answers — true AND false — for fields outside the scope (channel-mirror booleans)', async () => {
103
+ await Promise.all([false, true].map(async (answer) => {
104
+ const payload = await run({
105
+ customBooleanFieldAnswers: [
106
+ productBooleanAnswer({ id: 'chan-1', answer }),
107
+ deploymentBooleanAnswer({ id: 'chan-2', answer }),
108
+ ],
109
+ }, { restrictAnswersToFieldIds: ['some-form-field'] });
110
+ expect(payload).to.not.have.property('subscriptions');
111
+ expect(payload).to.not.have.property('deploymentTypes');
112
+ }));
113
+ });
114
+
115
+ it('keeps in-scope boolean answers with their real value — an explicit false checkbox flows', async () => {
116
+ const payload = await run({
117
+ customBooleanFieldAnswers: [
118
+ productBooleanAnswer({ id: 'form-product', answer: false }),
119
+ deploymentBooleanAnswer({ id: 'form-newsletter', answer: true }),
120
+ ],
121
+ }, { restrictAnswersToFieldIds: ['form-product', 'form-newsletter'] });
122
+ expect(payload.subscriptions).to.deep.equal([{ id: 28, receive: false }]);
123
+ expect(payload.deploymentTypes).to.deep.equal([{ id: 17, optedIn: true }]);
124
+ });
125
+
126
+ it('scopes boolean demographics too, but an in-scope false demographic still writes', async () => {
127
+ const payload = await run({
128
+ customBooleanFieldAnswers: [
129
+ demographicBooleanAnswer({ id: 'form-demo', answer: false }),
130
+ demographicBooleanAnswer({ id: 'other-demo', demographicId: 56, answer: true }),
131
+ ],
132
+ }, { restrictAnswersToFieldIds: ['form-demo'] });
133
+ expect(payload.demographics).to.deep.equal([{ id: 55, values: ['false'] }]);
134
+ });
135
+
136
+ it('scopes select answers: in-scope maps to demographics, out-of-scope is dropped', async () => {
137
+ const selectAnswer = (fieldId, demographicId, valueId) => ({
138
+ id: fieldId,
139
+ hasAnswered: true,
140
+ answers: [{ id: `opt-${valueId}`, externalIdentifier: `${valueId}` }],
141
+ field: {
142
+ id: fieldId,
143
+ active: true,
144
+ externalId: {
145
+ id: `omeda.demographic.${BRAND}*${demographicId}`,
146
+ namespace: { provider: 'omeda', tenant: BRAND, type: 'demographic' },
147
+ identifier: { value: `${demographicId}`, type: null },
148
+ },
149
+ },
150
+ });
151
+ const payload = await run({
152
+ customSelectFieldAnswers: [
153
+ selectAnswer('form-select', 68, 361),
154
+ selectAnswer('profile-only-select', 69, 374),
155
+ ],
156
+ }, { restrictAnswersToFieldIds: ['form-select'] });
157
+ expect(payload.demographics).to.deep.equal([{ id: 68, values: ['361'] }]);
158
+ });
159
+
160
+ it('applies no restriction when omitted or null (back-compat for form-submitting callers)', async () => {
161
+ await Promise.all([{}, { restrictAnswersToFieldIds: null }].map(async (opts) => {
162
+ const payload = await run({
163
+ customBooleanFieldAnswers: [productBooleanAnswer({ answer: false })],
164
+ }, opts);
165
+ expect(payload.subscriptions).to.deep.equal([{ id: 28, receive: false }]);
166
+ }));
167
+ });
168
+
169
+ it('compares ids as strings (numeric config ids match string field ids)', async () => {
170
+ const payload = await run({
171
+ customBooleanFieldAnswers: [productBooleanAnswer({ id: '123', answer: true })],
172
+ }, { restrictAnswersToFieldIds: [123] });
173
+ expect(payload.subscriptions).to.deep.equal([{ id: 28, receive: true }]);
174
+ });
175
+ });
176
+
177
+ describe('payload validation (the contract is enforced, not assumed)', () => {
178
+ const expectRejects = async (opts, match) => {
179
+ try {
180
+ await run({}, opts);
181
+ } catch (e) {
182
+ expect(e.message).to.match(match);
183
+ return;
184
+ }
185
+ throw new Error('expected the payload to be rejected');
186
+ };
187
+
188
+ it('rejects unknown keys — a mistyped restriction key must fail loudly, not mean "unrestricted"', async () => {
189
+ await expectRejects({ restrictAnswerToFieldIds: ['x'] }, /restrictAnswerToFieldIds/);
190
+ });
191
+
192
+ it('rejects a Set for restrictAnswersToFieldIds (the contract is array-only)', async () => {
193
+ await expectRejects({ restrictAnswersToFieldIds: new Set(['x']) }, /restrictAnswersToFieldIds/);
194
+ });
195
+
196
+ it('rejects malformed append entries', async () => {
197
+ await expectRejects({ appendSubscriptions: [{ id: 28 }] }, /receive/);
198
+ });
199
+
200
+ it('requires appUser.email and the identifier function', async () => {
201
+ let threw = false;
202
+ try {
203
+ await idxOmedaRapidIdentify({
204
+ brandKey: BRAND,
205
+ appUser: {},
206
+ identityX: {},
207
+ omedaRapidIdentify: async () => ({}),
208
+ });
209
+ } catch (e) { threw = true; }
210
+ expect(threw).to.equal(true);
211
+ });
212
+ });
82
213
  });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Returns the custom field ids the site's registration/login form presents — the
3
+ * `requiredCreateFieldRows` on the IdentityX configuration. This is the answer scope for the
4
+ * login-path rapid identifications: an answer for any other field is stored state (most
5
+ * dangerously idx-compat's channel-mirror booleans), not something the user said on this form.
6
+ *
7
+ * @param {import('@mindful-web/marko-web-identity-x/config')} idxConfig
8
+ * @returns {string[]} deduped, sorted (rapid-identify's payload contract is array-only)
9
+ */
10
+ module.exports = (idxConfig) => [...new Set(
11
+ (idxConfig.getRequiredCreateFieldRows() || [])
12
+ .flat()
13
+ .filter((def) => def && typeof def.type === 'string' && def.type.startsWith('custom-') && def.id)
14
+ .map((def) => `${def.id}`),
15
+ )].sort();
@@ -0,0 +1,7 @@
1
+ const Joi = require('@parameter1/joi');
2
+
3
+ module.exports = Joi.object({
4
+ id: Joi.number().required(),
5
+ receive: Joi.boolean().required(),
6
+ requestedVersion: Joi.string(),
7
+ });
@@ -0,0 +1,6 @@
1
+ const Joi = require('@parameter1/joi');
2
+
3
+ module.exports = Joi.object({
4
+ id: Joi.number().required(),
5
+ optedIn: Joi.boolean().required(),
6
+ });
@@ -1,7 +1,9 @@
1
1
  const appendBehavior = require('./append-behavior');
2
2
  const appendDemographic = require('./append-demographic');
3
3
  const appendPromoCode = require('./append-promo-code');
4
+ const appendSubscription = require('./append-subscription');
4
5
  const behavior = require('./behavior');
6
+ const deploymentType = require('./deployment-type');
5
7
  const hookBehavior = require('./hook-behavior');
6
8
  const hookDemographic = require('./hook-demographic');
7
9
  const hookPromoCode = require('./hook-promo-code');
@@ -11,7 +13,9 @@ module.exports = {
11
13
  appendBehavior,
12
14
  appendDemographic,
13
15
  appendPromoCode,
16
+ appendSubscription,
14
17
  behavior,
18
+ deploymentType,
15
19
  hookBehavior,
16
20
  hookDemographic,
17
21
  hookPromoCode,