@mindful-web/marko-web-omeda-identity-x 1.87.2 → 1.89.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.
@@ -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.87.2/components/identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-omeda-identity-x$1.89.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.87.2/components/identify.marko",
54
+ id: "/@mindful-web/marko-web-omeda-identity-x$1.89.0/components/identify.marko",
55
55
  component: "./identify.marko",
56
56
  tags: [
57
57
  "@mindful-web/marko-web-identity-x/components/identify.marko",
@@ -0,0 +1,41 @@
1
+ const { asArray } = require('@mindful-web/utils');
2
+ const getEncryptedId = require('./get-encrypted-customer-id');
3
+
4
+ /**
5
+ * **Every** encrypted Omeda customer id the member holds for this brand, deduped.
6
+ *
7
+ * Sibling of `find-encrypted-customer-id.js`, which returns the first match. This one returns them
8
+ * all, for the single caller that needs the Omeda service to *arbitrate* between them rather than
9
+ * pick locally: the `encryptedCustomerIds` sent to `rapidCustomerIdentification`, which decide
10
+ * which customer record receives the write.
11
+ *
12
+ * IdentityX external-id storage appends (`pushExternalSourcesForId` uses `$setUnion`), so a member
13
+ * accumulates ids and can hold several for one brand — 527 members on abmedia (0.65% of those
14
+ * holding any) and 2,932 on allured (2.0%), measured Aug 2026. Those sets are of two kinds, and
15
+ * only the service can tell them apart, because telling them apart requires resolving each id
16
+ * against Omeda:
17
+ *
18
+ * - a stale id plus the survivor it was merged into, which **converge** on one customer once
19
+ * resolved (~32% of them) — safe to use, and previously excluded for no reason;
20
+ * - two simultaneously **active** customers, i.e. a genuine duplicate pair (27 of 40 sampled) —
21
+ * where any choice is a guess, so the service falls back to email matching.
22
+ *
23
+ * **Do not pre-select an id here.** Position carries no information: the stored order is not
24
+ * creation order (13 matching vs 14 differing) and `$setUnion` does not guarantee ordering. And
25
+ * "newest" is the wrong instinct anyway — among divergent pairs the *older* record is the richer
26
+ * one 9 times to 2, because the newest id is typically the empty shell this bug just minted while
27
+ * the oldest is the member's real customer.
28
+ *
29
+ * Deliberately **not** a change to `find-encrypted-customer-id.js`. Its three other callers —
30
+ * `routes/rapid-identify.js`, `middleware/resync-customer-data.js` and
31
+ * `integration-hooks/on-authentication-success.js` — drive reads and the olytics cookie, where
32
+ * "any id for this brand" is correct and long-standing.
33
+ *
34
+ * @param {object} params
35
+ * @param {object[]} [params.externalIds] The IdentityX user's external ids.
36
+ * @param {string} params.brandKey The Omeda brand key.
37
+ * @returns {string[]} The cleaned encrypted ids, deduped. Empty when the member holds none.
38
+ */
39
+ module.exports = ({ externalIds, brandKey } = {}) => [...new Set(asArray(externalIds)
40
+ .map((externalId) => getEncryptedId({ externalId, brandKey }))
41
+ .filter((value) => value))];
@@ -77,6 +77,12 @@ module.exports = async (params = {}) => {
77
77
  ...(requiredFieldKeys.reduce((obj, key) => ({ ...obj, [key]: user[key] }), {})),
78
78
  id: user.id,
79
79
  email: user.email,
80
+ // Identifiers, not state — so they belong here even though this branch deliberately
81
+ // strips everything else. `rapid-identify` reads the member's stored encrypted Omeda
82
+ // id from these to identify the customer explicitly. Without it an unverified user —
83
+ // whose record is frequently email-only to begin with — gives Omeda nothing to match
84
+ // on but an email, which is precisely what mints a duplicate customer.
85
+ externalIds: user.externalIds,
80
86
  },
81
87
  behavior,
82
88
  promoCode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-omeda-identity-x",
3
- "version": "1.87.2",
3
+ "version": "1.89.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,8 +13,8 @@
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.87.1",
17
- "@mindful-web/marko-web-omeda": "^1.85.0",
16
+ "@mindful-web/marko-web-identity-x": "^1.89.0",
17
+ "@mindful-web/marko-web-omeda": "^1.88.0",
18
18
  "@mindful-web/object-path": "^1.83.1",
19
19
  "@mindful-web/utils": "^1.83.1",
20
20
  "@parameter1/joi": "^1.2.10",
@@ -33,5 +33,5 @@
33
33
  "chai": "^4.3.7",
34
34
  "mocha": "^6.2.3"
35
35
  },
36
- "gitHead": "6538c2484bbf419592ddfdbf82ead6d1e2e99c98"
36
+ "gitHead": "ef9a5dabc2cbaafb948233f37763a1bfd792b5a4"
37
37
  }
package/rapid-identify.js CHANGED
@@ -2,6 +2,8 @@ const gql = require('graphql-tag');
2
2
  const Joi = require('@parameter1/joi');
3
3
  const { validate } = require('@parameter1/joi/utils');
4
4
  const { get, getAsArray } = require('@mindful-web/object-path');
5
+ const olyticsCookie = require('@mindful-web/marko-web-omeda/olytics/customer-cookie');
6
+ const findEncryptedIds = require('./external-id/find-encrypted-customer-ids');
5
7
  const isOmedaDemographicId = require('./external-id/is-demographic-id');
6
8
  const isDeploymentTypeId = require('./external-id/is-deployment-type-id');
7
9
  const isProductId = require('./external-id/is-product-id');
@@ -213,8 +215,38 @@ module.exports = async (params = {}) => {
213
215
  appendPromoCodes.forEach((code) => promoCodes.push(code.promoCode));
214
216
  }
215
217
 
218
+ /**
219
+ * Identify the customer explicitly rather than letting Omeda guess from the payload.
220
+ *
221
+ * Omeda's identity resolution is heuristic, and a payload it cannot confidently match mints a
222
+ * *new* customer. That is not a rare edge: on athleticbusiness (Aug 2026) 8 of 11 duplicates were
223
+ * created at the exact second of a progressive-profile submit, and 7 of 8 were empty shells — no
224
+ * name, no company, no address — because that is what Omeda writes when it has only an email to
225
+ * go on. The progressive-profile audience is identified-but-not-authenticated members created
226
+ * seconds earlier from an email link, so their idx record legitimately holds nothing but an
227
+ * email; their payload can never carry contact fields. Their encrypted customer id, however, is
228
+ * written within a second of member creation — before the submit — so it is the one piece of
229
+ * identifying data those payloads *can* carry.
230
+ *
231
+ * Send *every* id held for the brand and let the service arbitrate. A member can hold several
232
+ * (storage appends), and only resolving them against Omeda distinguishes a stale id plus its
233
+ * merge survivor — which converge, and are safe — from two simultaneously active duplicate
234
+ * records, where any choice is a guess. The service uses the id only on convergence. Picking
235
+ * here is impossible anyway: stored order is not creation order, and the newest id is usually
236
+ * the shell this bug minted rather than the member's real customer.
237
+ *
238
+ * Empty → field omitted → the service's email-only behaviour, unchanged. Resolution failures
239
+ * (stale, merged, malformed, unknown) also fall back service-side, so a wrong id here costs
240
+ * nothing beyond a reported notice.
241
+ */
242
+ const storedEncryptedIds = findEncryptedIds({
243
+ externalIds: appUser.externalIds,
244
+ brandKey,
245
+ });
246
+
216
247
  const { id, encryptedCustomerId } = await omedaRapidIdentify({
217
248
  email: appUser.email,
249
+ ...(storedEncryptedIds.length && { encryptedCustomerIds: storedEncryptedIds }),
218
250
  ...(productId && { productId }),
219
251
  ...(givenName && { firstName: givenName }),
220
252
  ...(familyName && { lastName: familyName }),
@@ -257,6 +289,43 @@ module.exports = async (params = {}) => {
257
289
  throw new Error(`Omeda rapid identification for brand '${brandKey}' returned no ${missing.join(' and no ')}. No external IDs were written for IdentityX user ${appUser.id}.`);
258
290
  }
259
291
 
292
+ /**
293
+ * Omeda came back with a customer that none of the ids we sent point at.
294
+ *
295
+ * Two causes, both expected. A merge landed and the service followed the chain to a survivor we
296
+ * had never stored; or the ids were ambiguous, the service declined to choose, and email matching
297
+ * picked a record. Either way the write-back below **appends** (`$setUnion`), so the member ends
298
+ * up holding one more id than before.
299
+ *
300
+ * Reported, never acted on. More stored ids is not itself a problem now — the service resolves
301
+ * the whole set and uses it whenever they converge, which is what a genuine merge produces. What
302
+ * this counts is the case that does *not* converge: an additional simultaneously-active customer,
303
+ * i.e. a duplicate pair that needs merging in Omeda. That count sizes both follow-ups (the Omeda
304
+ * merges, and replace-semantics for idx external ids so a post-merge write-back retires the
305
+ * stale id). Deliberately non-fatal and deliberately not a write: identification has already
306
+ * succeeded, and changing external-id semantics under a merge is not something to improvise on
307
+ * the auth path.
308
+ */
309
+ const resolvedEncryptedId = olyticsCookie.clean(encryptedCustomerId);
310
+ if (storedEncryptedIds.length && !storedEncryptedIds.includes(resolvedEncryptedId)) {
311
+ // `config` is absent in unit harnesses and for any direct caller that stubs the service.
312
+ const { config } = identityX;
313
+ const onHookError = config && typeof config.get === 'function' ? config.get('onHookError') : null;
314
+ if (typeof onHookError === 'function') {
315
+ onHookError(
316
+ new Error(`Omeda resolved IdentityX user ${appUser.id} onto a customer none of the ${storedEncryptedIds.length} stored encrypted id(s) for brand '${brandKey}' point at. The prior id(s) are retained alongside the new one.`),
317
+ {
318
+ omedaIdChanged: true,
319
+ brandKey,
320
+ idxUserId: `${appUser.id}`,
321
+ storedEncryptedIds: storedEncryptedIds.join(','),
322
+ storedEncryptedIdCount: storedEncryptedIds.length,
323
+ resolvedEncryptedId,
324
+ },
325
+ );
326
+ }
327
+ }
328
+
260
329
  const namespace = { provider: 'omeda', tenant: brandKey.toLowerCase(), type: 'customer' };
261
330
  await Promise.all([
262
331
  identityX.addExternalUserId({
@@ -2,6 +2,7 @@ const { describe, it } = require('mocha');
2
2
  const { expect } = require('chai');
3
3
  const onAuthenticationSuccess = require('../integration-hooks/on-authentication-success');
4
4
  const onUserProfileUpdate = require('../integration-hooks/on-user-profile-update');
5
+ const onLoginLinkSent = require('../integration-hooks/on-login-link-sent');
5
6
 
6
7
  const BRAND = 'athlcd';
7
8
 
@@ -80,3 +81,71 @@ describe('integration-hooks/on-user-profile-update', () => {
80
81
  expect(h.captured()).to.not.have.property('restrictAnswersToFieldIds');
81
82
  });
82
83
  });
84
+
85
+ describe('integration-hooks/on-login-link-sent', () => {
86
+ // The unverified branch deliberately strips the stored user down to the create-form's required
87
+ // fields plus id/email -- state must not be rebroadcast as writes (#329). External ids are
88
+ // *identifiers*, not state, and `rapid-identify` needs the encrypted Omeda id from them to
89
+ // identify the customer explicitly. Without it an unverified member -- whose record is often
90
+ // email-only -- gives Omeda nothing but an email to match on, which is what mints duplicates.
91
+ const loginLinkSentWith = async (user) => {
92
+ let captured;
93
+ // The hook re-reads the *stored* user after identifying, to decide what to sync back.
94
+ const stored = {
95
+ customSelectFieldAnswers: [],
96
+ customBooleanFieldAnswers: [],
97
+ customTextFieldAnswers: [],
98
+ ...user,
99
+ };
100
+ await onLoginLinkSent({
101
+ behavior: { id: 9 },
102
+ brandKey: BRAND,
103
+ formatter: async ({ payload }) => payload,
104
+ idxOmedaRapidIdentify: async (payload) => {
105
+ captured = payload;
106
+ return { encryptedCustomerId: '0240G4865912F6U' };
107
+ },
108
+ omedaGraphQLClient: {
109
+ query: async () => ({ data: { customerByEncryptedId: { id: 1, firstName: 'Austin' } } }),
110
+ },
111
+ omedaPromoCodeCookieName: 'omeda_promo_code',
112
+ req: { cookies: {} },
113
+ source: 'test',
114
+ service: {
115
+ ...service(),
116
+ // No Omeda-linked custom fields, so the answer-sync half is a no-op.
117
+ client: {
118
+ query: async () => ({ data: { fields: { edges: [] } } }),
119
+ mutate: async () => ({ data: {} }),
120
+ },
121
+ getOrgUserApiToken: () => 'token',
122
+ },
123
+ user: stored,
124
+ });
125
+ return captured;
126
+ };
127
+
128
+ it('keeps externalIds on the stripped payload for an UNVERIFIED user', async () => {
129
+ const externalIds = [encryptedExternalId()];
130
+ const captured = await loginLinkSentWith({
131
+ id: 'idx-1',
132
+ email: 'someone@example.com',
133
+ verified: false,
134
+ givenName: 'Austin',
135
+ externalIds,
136
+ });
137
+ expect(captured.user.externalIds).to.deep.equal(externalIds);
138
+ expect(captured.user.email).to.equal('someone@example.com');
139
+ });
140
+
141
+ it('passes the full user through for a VERIFIED user, externalIds included', async () => {
142
+ const externalIds = [encryptedExternalId()];
143
+ const captured = await loginLinkSentWith({
144
+ id: 'idx-1',
145
+ email: 'someone@example.com',
146
+ verified: true,
147
+ externalIds,
148
+ });
149
+ expect(captured.user.externalIds).to.deep.equal(externalIds);
150
+ });
151
+ });
@@ -211,3 +211,112 @@ describe('rapid-identify (idx → omeda payload)', () => {
211
211
  });
212
212
  });
213
213
  });
214
+
215
+ const encryptedExternalId = (value) => ({
216
+ id: `omeda.customer.${BRAND}*${value}~encrypted`,
217
+ namespace: { provider: 'omeda', tenant: BRAND, type: 'customer' },
218
+ identifier: { value, type: 'encrypted' },
219
+ });
220
+
221
+ const numericExternalId = (value) => ({
222
+ id: `omeda.customer.${BRAND}*${value}`,
223
+ namespace: { provider: 'omeda', tenant: BRAND, type: 'customer' },
224
+ identifier: { value, type: null },
225
+ });
226
+
227
+ const ID_A = '9130C2719701F5S';
228
+ const ID_B = '6466A3060334H6A';
229
+
230
+ describe('rapid-identify (deterministic customer matching)', () => {
231
+ it('sends the stored encrypted id when the member has one for the brand', async () => {
232
+ // The whole point: identify the customer explicitly so Omeda's heuristics -- which mint
233
+ // duplicates on contact-field-empty payloads -- are bypassed.
234
+ const payload = await run({
235
+ externalIds: [numericExternalId('1105483508'), encryptedExternalId(ID_A)],
236
+ });
237
+ expect(payload.encryptedCustomerIds).to.deep.equal([ID_A]);
238
+ });
239
+
240
+ it('sends ALL ids when the member has several, and lets the service arbitrate', async () => {
241
+ // Only resolving them against Omeda distinguishes a stale id plus its merge survivor (which
242
+ // converge -- safe to use) from two simultaneously active duplicates (where any choice is a
243
+ // guess). Pre-selecting here would be a guess, and would strand ~a third of multi-id members
244
+ // on email matching for no reason.
245
+ const payload = await run({
246
+ externalIds: [encryptedExternalId(ID_A), encryptedExternalId(ID_B)],
247
+ });
248
+ expect(payload.encryptedCustomerIds).to.deep.equal([ID_A, ID_B]);
249
+ });
250
+
251
+ it('dedupes the same id stored twice, case-insensitively', async () => {
252
+ const payload = await run({
253
+ externalIds: [encryptedExternalId(ID_A), encryptedExternalId(ID_A.toLowerCase())],
254
+ });
255
+ expect(payload.encryptedCustomerIds).to.deep.equal([ID_A]);
256
+ });
257
+
258
+ it('omits the field when the member has none, or only ids for another brand', async () => {
259
+ expect(await run({})).to.not.have.property('encryptedCustomerIds');
260
+ expect(await run({ externalIds: [] })).to.not.have.property('encryptedCustomerIds');
261
+
262
+ const otherBrand = encryptedExternalId(ID_A);
263
+ otherBrand.namespace = { provider: 'omeda', tenant: 'allucd', type: 'customer' };
264
+ expect(await run({ externalIds: [otherBrand] })).to.not.have.property('encryptedCustomerIds');
265
+
266
+ // A numeric id is not an encrypted one.
267
+ const numericOnly = await run({ externalIds: [numericExternalId('1105483508')] });
268
+ expect(numericOnly).to.not.have.property('encryptedCustomerIds');
269
+ });
270
+
271
+ it('reports through onHookError when Omeda resolves onto a customer we did not send', async () => {
272
+ const reported = [];
273
+ await idxOmedaRapidIdentify({
274
+ brandKey: BRAND,
275
+ appUser: {
276
+ email: 'someone@example.com',
277
+ id: 'idx-1',
278
+ externalIds: [encryptedExternalId(ID_A)],
279
+ },
280
+ identityX: {
281
+ addExternalUserId: async () => ({}),
282
+ config: { get: (key) => (key === 'onHookError' ? (e, attrs) => reported.push({ e, attrs }) : null) },
283
+ },
284
+ omedaRapidIdentify: async () => ({ id: 2, encryptedCustomerId: ID_B }),
285
+ });
286
+
287
+ expect(reported).to.have.lengthOf(1);
288
+ expect(reported[0].attrs).to.deep.equal({
289
+ omedaIdChanged: true,
290
+ brandKey: BRAND,
291
+ idxUserId: 'idx-1',
292
+ storedEncryptedIds: ID_A,
293
+ storedEncryptedIdCount: 1,
294
+ resolvedEncryptedId: ID_B,
295
+ });
296
+ });
297
+
298
+ it('stays silent when the resolved id is one of the ids we sent', async () => {
299
+ const reported = [];
300
+ await idxOmedaRapidIdentify({
301
+ brandKey: BRAND,
302
+ appUser: {
303
+ email: 'someone@example.com',
304
+ id: 'idx-1',
305
+ // Ambiguous member: the service may legitimately answer with either one.
306
+ externalIds: [encryptedExternalId(ID_A), encryptedExternalId(ID_B)],
307
+ },
308
+ identityX: {
309
+ addExternalUserId: async () => ({}),
310
+ config: { get: () => (e) => reported.push(e) },
311
+ },
312
+ // Lowercase -- stored values are cleaned/uppercased, so this is the same id, not a merge.
313
+ omedaRapidIdentify: async () => ({ id: 2, encryptedCustomerId: ID_B.toLowerCase() }),
314
+ });
315
+ expect(reported).to.have.lengthOf(0);
316
+ });
317
+
318
+ it('does not throw when the service has no config (stubbed/direct callers)', async () => {
319
+ const payload = await run({ id: 'idx-1', externalIds: [encryptedExternalId(ID_A)] });
320
+ expect(payload.encryptedCustomerIds).to.deep.equal([ID_A]);
321
+ });
322
+ });