@mindful-web/marko-web-identity-x 1.85.3 → 1.85.5

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/CLAUDE.md CHANGED
@@ -348,7 +348,7 @@ configurations that are each a user-facing trap:
348
348
  | Boot check | Why the runtime failure is worse than a failed deploy |
349
349
  | --- | --- |
350
350
  | `type: 'built-in'` | Unwritable ⇒ permanent gate loop. **Silent** — the only one of these with no visible symptom until a user is stuck. |
351
- | `type: 'custom-boolean'` | `custom-column.vue` binds the checkbox to `customField.answer` but its handler writes `customField.value`, so a *required* checkbox can never be checked. The diff util, payload builder and mutation all handle booleans correctly — only the shared renderer does not. Re-allow once that is fixed. |
351
+ | `type: 'custom-boolean'` | The renderer bug that once made it unsatisfiable is fixed, but the gate forces every column `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked — a progressive boolean could only ever record `true`, a toll rather than an answer. Re-allow only if the gate ever supports optional columns or explicit yes/no booleans. |
352
352
  | any other type | Falls through to `custom-column.vue`'s `<pre v-else>` debug block, printing raw field metadata to a real visitor. |
353
353
  | id missing from a non-empty `activeCustomFieldIds` | `routes/profile.js:88-104` silently discards the answer ⇒ gate loop. |
354
354
  | rows configured with no `apiToken` | Every write uses `getOrgUserApiToken()` and the form token is signed from it. |
@@ -358,11 +358,11 @@ No coverage is lost: every field the fleet-wide impact analysis measured is a se
358
358
  ### Files
359
359
 
360
360
  - `utils/get-missing-progressive-fields.js` — the diff. Matches on **`answer.field.id`**, never
361
- `answer.id`. That distinction is the whole reason this feature does not reuse
362
- `get-form-custom-fields.js` (or `config.getGTMUserData`): an idx answer's `id` is the *answer's*
363
- id, so comparing it to a configured field id never matches, and the truthy-empty-array bug that
364
- follows returns an answer object with no `field`. Returns `missingRows` with every column forced
365
- `required: true`, so `custom-column.vue` needs no changes.
361
+ `answer.id`. That distinction is why this feature did not reuse `get-form-custom-fields.js`
362
+ (or `config.getGTMUserData`) at ship time: that util keyed on the answer's own `id` and its
363
+ truthy-empty-array check returned answer objects with no `field` — both fixed in
364
+ mindful-web#324, but the dedicated util stays (it also forces every column `required: true` in
365
+ the `missingRows` it returns, so `custom-column.vue` needs no changes).
366
366
  - `utils/get-progressive-form-fields.js` — correct, feature-scoped replacement for
367
367
  `get-form-custom-fields.js`. Stamps `id` with the **field** id, which is the contract
368
368
  `custom-column.vue` reads and what `get-create-user-custom-fields.js` already emits.
@@ -426,6 +426,22 @@ before the hooks run and essentially never rejects. An async failure is reported
426
426
  reach this field; `error` is a *synchronous* dispatch throw only. Deliberately **not** `onLoginLinkSent` / `onAuthenticationSuccess`: those drive
427
427
  auto-signup and the GA4 `subscribe` conversion, and this is not a registration.
428
428
 
429
+ **The hook payload strips `customBooleanFieldAnswers` — load-bearing, do not "restore" it.**
430
+ The omeda consumer (`marko-web-omeda-identity-x/rapid-identify.js`) maps *every* boolean answer on
431
+ the user it receives into provider writes: product-namespace booleans become
432
+ `subscriptions: [{ id, receive }]`, deployment-type booleans become opt-ins/opt-outs. idx-compat
433
+ surfaces every email/**magazine** channel as a boolean field whose "answer" is the member's stored
434
+ subscription state — `active: false` reads as an explicitly-answered no (`hasAnswered` is
435
+ `answer != null`; channels have no unanswered state). This form cannot collect booleans
436
+ (boot-validation forbids the rows), so any boolean reaching the hook is state the visitor never
437
+ touched. Passing them through let a progressive submit overwrite Omeda subscription state
438
+ wholesale: 18 of the first 48 production submitters on athleticbusiness had their print-magazine
439
+ subscription killed (`Receive: 0`, "Controlled Kills") at their exact submit second — the
440
+ 2026-08-19 incident. The spec asserting the payload shape lives in
441
+ `marko-web-omeda-identity-x/test/rapid-identify.spec.js`. The profile route (`routes/profile.js`)
442
+ still passes booleans deliberately — its form renders the subscription checkboxes — but it shares
443
+ the divergence exposure; see the incident follow-ups in the rollout doc.
444
+
429
445
  ### Bot protection — verify, then discard the write rather than the visitor
430
446
 
431
447
  The threat is concrete: `marko-web-omeda-identity-x/middleware/set-identity-cookie.js` mints
@@ -612,18 +628,19 @@ reaches the bridge. The content entity is passed as `emit`'s third argument inst
612
628
  Submitted **answer values are never tracked** anywhere — `fieldKeys` and the member-event's
613
629
  `fieldIds` record which questions were asked, nothing more.
614
630
 
615
- ### Two live follow-ups, neither in this package
616
-
617
- 1. 🔴 **Index drop/recreate, production.** `completed_auth_date_website_channel` and
618
- `completed_auth_email_website_channel` had their `partialFilterExpression` widened; a changed
619
- partial filter does not alter an index in place, so both must be dropped and recreated. Until
620
- then progressive-profiling `member-events` queries collection-scan. Owner: audience/infra.
621
- 2. **The shared-code PR.** `get-form-custom-fields.js`, the commented-out `custom-boolean` branches
622
- in `generate-required-field-payload.js`, the `routes/profile.js` allowlist, `custom-column.vue`'s
623
- boolean binding, and the p1events bridge's `contentGatingType`/`contentGateType` mismatch (every
624
- producer in the fleet sets the latter, so gate type has **never** reached p1events for any
625
- gate). All shared with live flows across 18 repos — deliberately not fixed alongside a new
626
- feature.
631
+ ### Follow-ups — both resolved 2026-08-15
632
+
633
+ 1. ✅ **Index drop/recreate, production** — done. Both `completed_auth_*` indexes in production
634
+ carry `PROGRESSIVE_PROFILE_SUBMIT` in their partial filters (verified on `audience:abmedia`
635
+ and `audience:transpire`).
636
+ 2. ✅ **The shared-code PR** — landed as mindful-web#324 (this package: `get-form-custom-fields.js`
637
+ field-id keying, `custom-column.vue` boolean binding, `routes/profile.js` allowlist,
638
+ `generate-required-field-payload.js` boolean branches + `hasAnswered` false-boolean fix) and
639
+ mindful-web#325 (the p1events bridge's `contentGatingType`→`contentGateType` rename — the gate
640
+ type had never reached p1events on any conversion event). The allured copied-meter frozen-clock
641
+ bug went separately as allured-business-media-websites#461. Progressive-profile field rows
642
+ still exclude `custom-boolean` — no longer for the renderer bug, but because the gate forces
643
+ required columns and a required checkbox can only submit `true` (see the boot-check table).
627
644
 
628
645
  ## Fleet / rollout
629
646
 
@@ -353,9 +353,14 @@ export default {
353
353
  /**
354
354
  *
355
355
  */
356
- onCustomBooleanChange(field) {
356
+ onCustomBooleanChange(field, checked) {
357
+ // `.answer` is canonical (it's what the template binds and what `routes/profile.js`
358
+ // reads); `.value` is kept in sync because the payload builders read it. Assign from the
359
+ // emitted event rather than toggling, so the model can never invert against the checkbox.
357
360
  // eslint-disable-next-line no-param-reassign
358
- field.value = !field.value;
361
+ field.answer = checked;
362
+ // eslint-disable-next-line no-param-reassign
363
+ field.value = checked;
359
364
  // eslint-disable-next-line no-param-reassign
360
365
  field.hasAnswered = true;
361
366
  this.user.customBooleanFieldAnswers = this.customBooleanFieldAnswers;
@@ -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-identity-x$1.85.3/components/access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/access.marko",
6
6
  marko_component = require("./access.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -53,7 +53,7 @@ marko_template._ = marko_renderer(render, {
53
53
  }, marko_component);
54
54
 
55
55
  marko_template.meta = {
56
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/access.marko",
56
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/access.marko",
57
57
  component: "./access.marko",
58
58
  tags: [
59
59
  "@mindful-web/marko-core/components/resolve.marko"
@@ -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-identity-x$1.85.3/components/comment-stream.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/comment-stream.marko",
6
6
  marko_component = require("./comment-stream.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -91,7 +91,7 @@ marko_template._ = marko_renderer(render, {
91
91
  }, marko_component);
92
92
 
93
93
  marko_template.meta = {
94
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/comment-stream.marko",
94
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/comment-stream.marko",
95
95
  component: "./comment-stream.marko",
96
96
  tags: [
97
97
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -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-identity-x$1.85.3/components/context.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/context.marko",
6
6
  marko_component = require("./context.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
@@ -49,7 +49,7 @@ marko_template._ = marko_renderer(render, {
49
49
  }, marko_component);
50
50
 
51
51
  marko_template.meta = {
52
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/context.marko",
52
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/context.marko",
53
53
  component: "./context.marko",
54
54
  tags: [
55
55
  "@mindful-web/marko-core/components/resolve.marko"
@@ -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-identity-x$1.85.3/components/form-access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/form-access.marko",
6
6
  marko_component = require("./form-access.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
@@ -179,7 +179,7 @@ marko_template._ = marko_renderer(render, {
179
179
  }, marko_component);
180
180
 
181
181
  marko_template.meta = {
182
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/form-access.marko",
182
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/form-access.marko",
183
183
  component: "./form-access.marko",
184
184
  tags: [
185
185
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -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-identity-x$1.85.3/components/form-authenticate.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/form-authenticate.marko",
6
6
  marko_component = require("./form-authenticate.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -81,7 +81,7 @@ marko_template._ = marko_renderer(render, {
81
81
  }, marko_component);
82
82
 
83
83
  marko_template.meta = {
84
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/form-authenticate.marko",
84
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/form-authenticate.marko",
85
85
  component: "./form-authenticate.marko",
86
86
  tags: [
87
87
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -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-identity-x$1.85.3/components/form-change-email.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/form-change-email.marko",
6
6
  marko_component = require("./form-change-email.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -59,7 +59,7 @@ marko_template._ = marko_renderer(render, {
59
59
  }, marko_component);
60
60
 
61
61
  marko_template.meta = {
62
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/form-change-email.marko",
62
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/form-change-email.marko",
63
63
  component: "./form-change-email.marko",
64
64
  tags: [
65
65
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -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-identity-x$1.85.3/components/form-login.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/form-login.marko",
6
6
  marko_component = require("./form-login.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -92,7 +92,7 @@ marko_template._ = marko_renderer(render, {
92
92
  }, marko_component);
93
93
 
94
94
  marko_template.meta = {
95
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/form-login.marko",
95
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/form-login.marko",
96
96
  component: "./form-login.marko",
97
97
  tags: [
98
98
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -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-identity-x$1.85.3/components/form-logout.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/form-logout.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
8
8
  defaultValue = module_defaultValue.default || module_defaultValue,
@@ -29,7 +29,7 @@ marko_template._ = marko_renderer(render, {
29
29
  });
30
30
 
31
31
  marko_template.meta = {
32
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/form-logout.marko",
32
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/form-logout.marko",
33
33
  tags: [
34
34
  "@mindful-web/marko-web/components/browser-component.marko"
35
35
  ]
@@ -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-identity-x$1.85.3/components/form-profile.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/form-profile.marko",
6
6
  marko_component = require("./form-profile.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -84,7 +84,7 @@ marko_template._ = marko_renderer(render, {
84
84
  }, marko_component);
85
85
 
86
86
  marko_template.meta = {
87
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/form-profile.marko",
87
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/form-profile.marko",
88
88
  component: "./form-profile.marko",
89
89
  tags: [
90
90
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -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-identity-x$1.85.3/components/form-register.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/form-register.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  marko_assign = require("marko/dist/runtime/helpers/assign"),
8
8
  marko_web_identity_x_form_login_template = require("./form-login.marko"),
@@ -25,7 +25,7 @@ marko_template._ = marko_renderer(render, {
25
25
  });
26
26
 
27
27
  marko_template.meta = {
28
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/form-register.marko",
28
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/form-register.marko",
29
29
  tags: [
30
30
  "./form-login.marko"
31
31
  ]
@@ -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-identity-x$1.85.3/components/google-init.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/google-init.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  marko_attr = require("marko/dist/runtime/html/helpers/attr"),
8
8
  marko_str = require("marko/dist/runtime/helpers/to-string");
@@ -239,5 +239,5 @@ marko_template._ = marko_renderer(render, {
239
239
  });
240
240
 
241
241
  marko_template.meta = {
242
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/google-init.marko"
242
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/google-init.marko"
243
243
  };
@@ -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-identity-x$1.85.3/components/google-sign-in-button.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/google-sign-in-button.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  helpers_escape_xml = require("marko/dist/runtime/html/helpers/escape-xml"),
8
8
  marko_escapeXml = helpers_escape_xml.x,
@@ -40,5 +40,5 @@ marko_template._ = marko_renderer(render, {
40
40
  });
41
41
 
42
42
  marko_template.meta = {
43
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/google-sign-in-button.marko"
43
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/google-sign-in-button.marko"
44
44
  };
@@ -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-identity-x$1.85.3/components/identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/identify.marko",
6
6
  marko_component = require("./identify.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -50,7 +50,7 @@ marko_template._ = marko_renderer(render, {
50
50
  }, marko_component);
51
51
 
52
52
  marko_template.meta = {
53
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/identify.marko",
53
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/identify.marko",
54
54
  component: "./identify.marko",
55
55
  tags: [
56
56
  "@mindful-web/marko-web-gtm/components/push.marko",
@@ -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-identity-x$1.85.3/components/non-auth-identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/non-auth-identify.marko",
6
6
  marko_component = require("./non-auth-identify.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
@@ -45,7 +45,7 @@ marko_template._ = marko_renderer(render, {
45
45
  }, marko_component);
46
46
 
47
47
  marko_template.meta = {
48
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/non-auth-identify.marko",
48
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/non-auth-identify.marko",
49
49
  component: "./non-auth-identify.marko",
50
50
  tags: [
51
51
  "@mindful-web/marko-core/components/resolve.marko"
@@ -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-identity-x$1.85.3/components/progressive-profile-form.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/progressive-profile-form.marko",
6
6
  marko_component = require("./progressive-profile-form.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
@@ -137,7 +137,7 @@ marko_template._ = marko_renderer(render, {
137
137
  }, marko_component);
138
138
 
139
139
  marko_template.meta = {
140
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/progressive-profile-form.marko",
140
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/progressive-profile-form.marko",
141
141
  component: "./progressive-profile-form.marko",
142
142
  tags: [
143
143
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -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-identity-x$1.85.3/components/subscribe.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.85.5/components/subscribe.marko",
6
6
  marko_component = require("./subscribe.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -73,7 +73,7 @@ marko_template._ = marko_renderer(render, {
73
73
  }, marko_component);
74
74
 
75
75
  marko_template.meta = {
76
- id: "/@mindful-web/marko-web-identity-x$1.85.3/components/subscribe.marko",
76
+ id: "/@mindful-web/marko-web-identity-x$1.85.5/components/subscribe.marko",
77
77
  component: "./subscribe.marko",
78
78
  tags: [
79
79
  "@mindful-web/marko-web/components/browser-component.marko",
package/config.js CHANGED
@@ -15,12 +15,12 @@ const { log } = console;
15
15
  * attributes-only (core/root member fields were cut on review), so a built-in column renders,
16
16
  * accepts a value, and is then silently dropped: the user submits, gets `changed: false`, and is
17
17
  * re-gated forever. This is the only one of these checks whose runtime failure is *silent*.
18
- * - **`custom-boolean` is unsatisfiable.** `custom-column.vue` binds the checkbox to
19
- * `customField.answer` but its change handler writes `customField.value`, so the box never
20
- * registers as checked — and a *required* checkbox that cannot be checked traps the user behind
21
- * HTML5 validation. The diff util, the payload builder and the mutation all handle booleans
22
- * correctly; only the shared renderer does not, and that is fixed in the separate shared-code
23
- * PR rather than here. Remove `custom-boolean` from this list once it is.
18
+ * - **`custom-boolean` is semantically useless here.** The renderer bug that once made it
19
+ * unsatisfiable (custom-column.vue read `.answer` but wrote `.value`) is fixed, and the diff
20
+ * util, payload builder, and mutation all handle booleans — but the gate forces every column
21
+ * `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked. A
22
+ * progressive boolean could therefore only ever record `true`, which is not an answer, it's a
23
+ * toll. Re-allow only if the gate ever supports optional columns or explicit yes/no booleans.
24
24
  *
25
25
  * No coverage is lost: every field the fleet-wide impact analysis measured is a select attribute.
26
26
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-identity-x",
3
- "version": "1.85.3",
3
+ "version": "1.85.5",
4
4
  "description": "Marko Wrapper for IdentityX",
5
5
  "repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-identity-x",
6
6
  "author": "Josh Worden <josh@parameter1.com>",
@@ -10,7 +10,7 @@
10
10
  "lint": "eslint --ext .js --ext .vue --max-warnings 5 ./",
11
11
  "compile": "mindful-web-marko-compile compile",
12
12
  "prepublish": "yarn compile --silent",
13
- "test": "yarn compile --no-clean && yarn lint"
13
+ "test": "yarn compile --no-clean && yarn lint && mocha --reporter spec"
14
14
  },
15
15
  "dependencies": {
16
16
  "@mindful-web/marko-web-gtm": "^1.83.1",
@@ -39,5 +39,9 @@
39
39
  "publishConfig": {
40
40
  "access": "public"
41
41
  },
42
- "gitHead": "34418dcc1061957e9782f403c17093281f08c07d"
42
+ "devDependencies": {
43
+ "chai": "^4.3.7",
44
+ "mocha": "^6.2.3"
45
+ },
46
+ "gitHead": "26553d897cb2c39e798031e4e2727a81123b1402"
43
47
  }
package/routes/profile.js CHANGED
@@ -85,13 +85,17 @@ module.exports = asyncRoute(async (req, res) => {
85
85
  receiveEmail,
86
86
  };
87
87
 
88
- // get identity-x.forms custom-select question ids to append to ActiveCustomQuestionIds
88
+ // get identity-x.forms custom question ids to append to ActiveCustomQuestionIds
89
89
  const customFormFieldIds = Object.values(getAsObject(identityX, 'config.options.forms')).reduce((questions, form) => {
90
90
  // loop over the fieldRows
91
91
  getAsArray(form, 'fieldRows').forEach((row) => row.forEach((question) => {
92
- // push the custom-select form being used with those forms to acceptable profile inputs
93
- // @todo figure out if other types should also make it... custom-boolean specifically.
94
- if (question.type === 'custom-select') questions.push(question.id);
92
+ // Every custom question a form renders must be writable on submit. This list filters all
93
+ // three answer mutations below, so omitting a type here (custom-select-only, previously)
94
+ // silently discarded that form's boolean/text answers unless the site also listed the id
95
+ // in `activeCustomFieldIds`.
96
+ if (question.id && typeof question.type === 'string' && question.type.startsWith('custom-')) {
97
+ questions.push(question.id);
98
+ }
95
99
  }));
96
100
  return questions;
97
101
  }, []);
@@ -240,7 +240,17 @@ const submit = asyncRoute(async (req, res) => {
240
240
  try {
241
241
  await callHooksFor(identityX, 'onUserProfileUpdate', {
242
242
  req,
243
- user: appUser,
243
+ // Boolean answers are stripped, deliberately and load-bearingly. The omeda hook maps
244
+ // EVERY boolean answer on the user it receives into provider writes -- product booleans
245
+ // become `subscriptions: [{ id, receive }]`, deployment-type booleans become opt-ins/
246
+ // opt-outs -- and idx-compat surfaces every email/magazine *channel* as a boolean whose
247
+ // "answer" is the member's stored subscription state (`active: false` reads as an
248
+ // explicitly-answered no; channels have no unanswered state). This form cannot collect
249
+ // booleans (boot-validation forbids the rows), so any boolean here is by definition
250
+ // state the visitor never touched -- and passing it through let a progressive submit
251
+ // overwrite Omeda subscription state wholesale, unsubscribing magazine subscribers
252
+ // whose idx state had diverged (2026-08-19 incident, athleticbusiness).
253
+ user: { ...appUser, customBooleanFieldAnswers: [] },
244
254
  additionalEventData: { actionSource: ACTION_SOURCE },
245
255
  actionSource: ACTION_SOURCE,
246
256
  });
@@ -0,0 +1,2 @@
1
+ --recursive
2
+ --timeout 2000
@@ -0,0 +1,92 @@
1
+ const { describe, it } = require('mocha');
2
+ const { expect } = require('chai');
3
+ const generate = require('../../utils/generate-required-field-payload');
4
+
5
+ const boolDef = {
6
+ type: 'custom-boolean', id: 'bool-1', key: 'bool-1', required: true,
7
+ };
8
+ const selectDef = {
9
+ type: 'custom-select', id: 'select-1', key: 'select-1', required: true,
10
+ };
11
+
12
+ const boolAnswer = (answer, { fieldId = 'bool-1', hasAnswered = true } = {}) => ({
13
+ id: fieldId, hasAnswered, answer, value: answer, field: { id: fieldId },
14
+ });
15
+ const selectAnswer = (optionIds, { fieldId = 'select-1' } = {}) => ({
16
+ id: fieldId,
17
+ hasAnswered: Boolean(optionIds.length),
18
+ answers: optionIds.map((id) => ({ id })),
19
+ field: { id: fieldId },
20
+ });
21
+
22
+ describe('utils/generate-required-field-payload', () => {
23
+ describe('required custom-boolean fields', () => {
24
+ it('should treat an answered `false` as answered', () => {
25
+ const out = generate({
26
+ appUser: { customBooleanFieldAnswers: [boolAnswer(false)] },
27
+ body: {},
28
+ definitions: [boolDef],
29
+ });
30
+ expect(out.appUserHasAllRequiredFields).to.equal(true);
31
+ expect(out.hasAllRequiredFields).to.equal(true);
32
+ });
33
+
34
+ it('should treat an unanswered boolean as missing', () => {
35
+ const out = generate({
36
+ appUser: { customBooleanFieldAnswers: [boolAnswer(null, { hasAnswered: false })] },
37
+ body: {},
38
+ definitions: [boolDef],
39
+ });
40
+ expect(out.appUserHasAllRequiredFields).to.equal(false);
41
+ expect(out.hasAllRequiredFields).to.equal(false);
42
+ });
43
+
44
+ it('should satisfy requirements from the submitted body and build the payload', () => {
45
+ const out = generate({
46
+ appUser: {},
47
+ body: { customBooleanFieldAnswers: [boolAnswer(true)] },
48
+ definitions: [boolDef],
49
+ });
50
+ expect(out.appUserHasAllRequiredFields).to.equal(false);
51
+ expect(out.hasAllRequiredFields).to.equal(true);
52
+ expect(out.payload.customBooleanFieldAnswers).to.deep.equal([
53
+ { fieldId: 'bool-1', value: true },
54
+ ]);
55
+ });
56
+
57
+ it('should carry a submitted `false` into the payload as false', () => {
58
+ const out = generate({
59
+ appUser: {},
60
+ body: { customBooleanFieldAnswers: [boolAnswer(false)] },
61
+ definitions: [boolDef],
62
+ });
63
+ expect(out.hasAllRequiredFields).to.equal(true);
64
+ expect(out.payload.customBooleanFieldAnswers).to.deep.equal([
65
+ { fieldId: 'bool-1', value: false },
66
+ ]);
67
+ });
68
+ });
69
+
70
+ describe('required custom-select fields (existing behavior preserved)', () => {
71
+ it('should treat an empty answers array as unanswered', () => {
72
+ const out = generate({
73
+ appUser: { customSelectFieldAnswers: [selectAnswer([])] },
74
+ body: {},
75
+ definitions: [selectDef],
76
+ });
77
+ expect(out.appUserHasAllRequiredFields).to.equal(false);
78
+ });
79
+
80
+ it('should satisfy from the app user and exclude satisfied fields from the body payload', () => {
81
+ const out = generate({
82
+ appUser: { customSelectFieldAnswers: [selectAnswer(['opt-1'])] },
83
+ body: {},
84
+ definitions: [selectDef],
85
+ });
86
+ expect(out.appUserHasAllRequiredFields).to.equal(true);
87
+ expect(out.payload.customSelectFieldAnswers).to.deep.equal([
88
+ { fieldId: 'select-1', optionIds: ['opt-1'], writeInValues: [] },
89
+ ]);
90
+ });
91
+ });
92
+ });
@@ -0,0 +1,76 @@
1
+ const { describe, it } = require('mocha');
2
+ const { expect } = require('chai');
3
+ const getFormCustomFields = require('../../utils/get-form-custom-fields');
4
+
5
+ const fields = [
6
+ { id: 'bool-field-1', __typename: 'BooleanField', label: 'Opt in?' },
7
+ { id: 'select-field-1', __typename: 'SelectField', label: 'Title' },
8
+ { id: 'text-field-1', __typename: 'TextField', label: 'Nickname' },
9
+ ];
10
+ const fieldRows = [[{ id: 'bool-field-1' }, { id: 'select-field-1' }, { id: 'text-field-1' }]];
11
+
12
+ describe('utils/get-form-custom-fields', () => {
13
+ it('should return unanswered stubs carrying the field id when there is no user', () => {
14
+ const out = getFormCustomFields({ fields, fieldRows, user: null });
15
+ expect(out.customBooleanFieldAnswers).to.deep.equal([
16
+ {
17
+ id: 'bool-field-1', hasAnswered: false, answer: null, value: null, field: fields[0],
18
+ },
19
+ ]);
20
+ expect(out.customSelectFieldAnswers[0]).to.include({ id: 'select-field-1', hasAnswered: false });
21
+ expect(out.customTextFieldAnswers[0]).to.include({ id: 'text-field-1', hasAnswered: false });
22
+ });
23
+
24
+ it('should key user answers by field.id, not the answer document id', () => {
25
+ const user = {
26
+ customBooleanFieldAnswers: [],
27
+ customSelectFieldAnswers: [
28
+ // `id` here is an answer-document id that does NOT equal the field id.
29
+ {
30
+ id: 'answer-doc-9', hasAnswered: true, answers: [{ id: 'opt-1' }], field: { id: 'select-field-1' },
31
+ },
32
+ ],
33
+ customTextFieldAnswers: [],
34
+ };
35
+ const out = getFormCustomFields({ fields, fieldRows, user });
36
+ const [select] = out.customSelectFieldAnswers;
37
+ expect(select.hasAnswered).to.equal(true);
38
+ expect(select.answers).to.deep.equal([{ id: 'opt-1' }]);
39
+ // Normalized for custom-column.vue, which finds answers via `ans.id === fieldId`.
40
+ expect(select.id).to.equal('select-field-1');
41
+ expect(select.field).to.equal(fields[1]);
42
+ });
43
+
44
+ it('should return an unanswered stub (not an empty object) when the user has no matching answer', () => {
45
+ const user = {
46
+ customBooleanFieldAnswers: [
47
+ {
48
+ id: 'other', hasAnswered: true, answer: true, field: { id: 'some-other-field' },
49
+ },
50
+ ],
51
+ customSelectFieldAnswers: [],
52
+ customTextFieldAnswers: [],
53
+ };
54
+ const out = getFormCustomFields({ fields, fieldRows, user });
55
+ const [bool] = out.customBooleanFieldAnswers;
56
+ expect(bool.id).to.equal('bool-field-1');
57
+ expect(bool.hasAnswered).to.equal(false);
58
+ expect(bool.field).to.equal(fields[0]);
59
+ });
60
+
61
+ it('should preserve a false boolean answer', () => {
62
+ const user = {
63
+ customBooleanFieldAnswers: [
64
+ {
65
+ id: 'bool-field-1', hasAnswered: true, answer: false, value: false, field: { id: 'bool-field-1' },
66
+ },
67
+ ],
68
+ customSelectFieldAnswers: [],
69
+ customTextFieldAnswers: [],
70
+ };
71
+ const out = getFormCustomFields({ fields, fieldRows, user });
72
+ const [bool] = out.customBooleanFieldAnswers;
73
+ expect(bool.hasAnswered).to.equal(true);
74
+ expect(bool.answer).to.equal(false);
75
+ });
76
+ });
@@ -1,7 +1,13 @@
1
1
  const { get } = require('@mindful-web/object-path');
2
2
 
3
- // eslint-disable-next-line max-len
4
- const hasAnswered = (ans) => ans && ans.hasAnswered && (ans.value || (ans.answers && ans.answers.length));
3
+ const hasAnswered = (ans) => {
4
+ if (!ans || !ans.hasAnswered) return false;
5
+ // A boolean field is answered by having been answered — `false` is a legitimate value that
6
+ // must not read as missing, so never truth-test it. Boolean answers carry a boolean
7
+ // `answer`/`value`; selects carry `answers`, texts a string `value`.
8
+ if (typeof ans.answer === 'boolean' || typeof ans.value === 'boolean') return true;
9
+ return Boolean(ans.value || (ans.answers && ans.answers.length));
10
+ };
5
11
 
6
12
  const hasMappedAnswers = (map) => (id) => {
7
13
  const ans = map.get(id);
@@ -40,10 +46,10 @@ module.exports = ({ appUser, body, definitions }) => {
40
46
 
41
47
  const requiredFieldKeys = [...requiredFieldKeySet];
42
48
 
43
- // const requiredCustomBooleans = [...definitions.reduce((set, def) => {
44
- // if (def.type === 'custom-boolean' && def.required) set.add(def.id);
45
- // return set;
46
- // }, new Set())];
49
+ const requiredCustomBooleans = [...definitions.reduce((set, def) => {
50
+ if (def.type === 'custom-boolean' && def.required) set.add(def.id);
51
+ return set;
52
+ }, new Set())];
47
53
 
48
54
  const requiredCustomSelects = [...definitions.reduce((set, def) => {
49
55
  if (def.type === 'custom-select' && def.required) set.add(def.id);
@@ -56,15 +62,15 @@ module.exports = ({ appUser, body, definitions }) => {
56
62
  }, new Set())];
57
63
 
58
64
  const userCustomFieldAnswers = new Map([
59
- // ...(get(appUser, 'customBooleanFieldAnswers') || []).map((a) => [a.field.id, a]),
65
+ ...(get(appUser, 'customBooleanFieldAnswers') || []).map((a) => [a.field.id, a]),
60
66
  ...(get(appUser, 'customSelectFieldAnswers') || []).map((a) => [a.field.id, a]),
61
67
  ...(get(appUser, 'customTextFieldAnswers') || []).map((a) => [a.field.id, a]),
62
68
  ]);
63
69
 
64
70
  const customFieldAnswers = new Map([
65
71
  ...userCustomFieldAnswers,
66
- // ...(get(body, 'customBooleanFieldAnswers') || [])
67
- // .filter(hasAnswered).map((a) => [a.field.id, a]),
72
+ ...(get(body, 'customBooleanFieldAnswers') || [])
73
+ .filter(hasAnswered).map((a) => [a.field.id, a]),
68
74
  ...(get(body, 'customSelectFieldAnswers') || []).filter(hasAnswered).map((a) => [a.field.id, a]),
69
75
  ...(get(body, 'customTextFieldAnswers') || [])
70
76
  .filter(hasAnswered).map((a) => [a.field.id, a]),
@@ -74,9 +80,7 @@ module.exports = ({ appUser, body, definitions }) => {
74
80
  requiredFieldKeys.every(
75
81
  (key) => get(appUser, key),
76
82
  ),
77
- // requiredCustomBooleans.every(
78
- // (id) => userCustomFieldAnswers.has(id) && userCustomFieldAnswers.get(id).hasAnswered,
79
- // ),
83
+ requiredCustomBooleans.every(hasMappedAnswers(userCustomFieldAnswers)),
80
84
  requiredCustomSelects.every(hasMappedAnswers(userCustomFieldAnswers)),
81
85
  requiredCustomTexts.every(
82
86
  (id) => userCustomFieldAnswers.has(id) && userCustomFieldAnswers.get(id).hasAnswered,
@@ -87,14 +91,23 @@ module.exports = ({ appUser, body, definitions }) => {
87
91
  requiredFieldKeys.every(
88
92
  (key) => get(appUser, key) || get(body, key),
89
93
  ),
90
- // requiredCustomBooleans.every(hasMappedAnswers(customFieldAnswers)),
94
+ requiredCustomBooleans.every(hasMappedAnswers(customFieldAnswers)),
91
95
  requiredCustomSelects.every(hasMappedAnswers(customFieldAnswers)),
92
96
  requiredCustomTexts.every(hasMappedAnswers(customFieldAnswers)),
93
97
  ].every((v) => v);
94
98
 
95
99
  const payload = {
96
100
  ...(requiredFieldKeys.reduce((obj, key) => ({ ...obj, [key]: body[key] }), {})),
97
- // customBooleanFieldAnswers: [], // @todo
101
+ customBooleanFieldAnswers: requiredCustomBooleans
102
+ .filter(hasMappedAnswers(customFieldAnswers))
103
+ .map((id) => {
104
+ const answer = customFieldAnswers.get(id);
105
+ return {
106
+ fieldId: answer.field.id,
107
+ // `.answer` is canonical (what the profile route reads); fall back to `.value`.
108
+ value: Boolean(typeof answer.answer === 'boolean' ? answer.answer : answer.value),
109
+ };
110
+ }),
98
111
  customSelectFieldAnswers: requiredCustomSelects
99
112
  .filter(hasMappedAnswers(customFieldAnswers))
100
113
  .map((id) => {
@@ -11,44 +11,35 @@ module.exports = ({
11
11
  });
12
12
  });
13
13
 
14
+ // A user answer is keyed by its `field.id` — never the answer's own `id`, which some backends
15
+ // set to the answer document id. The returned object always carries `id` = the field id and
16
+ // `field` = the form's field definition, because `custom-column.vue` finds answers via
17
+ // `ans.id === fieldId` and renders from `customField.field`.
18
+ const findUserAnswer = (answers, f) => {
19
+ const found = (answers || []).find((a) => a && a.field && a.field.id === f.id);
20
+ return found ? { ...found, id: f.id, field: f } : null;
21
+ };
22
+
14
23
  // eslint-disable-next-line no-underscore-dangle
15
24
  const customBooleanFieldAnswers = fieldRowFields.filter((f) => f.__typename === 'BooleanField').map((f) => {
16
- if (user) {
17
- const userAnswer = user.customBooleanFieldAnswers.filter(({ id }) => id === f.id);
18
- if (userAnswer) {
19
- return ({
20
- ...userAnswer[0],
21
- });
22
- }
23
- }
25
+ const userAnswer = user ? findUserAnswer(user.customBooleanFieldAnswers, f) : null;
26
+ if (userAnswer) return userAnswer;
24
27
  return ({
25
28
  id: f.id, hasAnswered: false, answer: null, value: null, field: f,
26
29
  });
27
30
  });
28
- // eslint-disable-next-line no-underscore-dangle, eqeqeq
29
- const customSelectFieldAnswers = fieldRowFields.filter((f) => f.__typename == 'SelectField').map((f) => {
30
- if (user) {
31
- const userAnswer = user.customSelectFieldAnswers.filter(({ id }) => id === f.id);
32
- if (userAnswer) {
33
- return ({
34
- ...userAnswer[0],
35
- });
36
- }
37
- }
31
+ // eslint-disable-next-line no-underscore-dangle
32
+ const customSelectFieldAnswers = fieldRowFields.filter((f) => f.__typename === 'SelectField').map((f) => {
33
+ const userAnswer = user ? findUserAnswer(user.customSelectFieldAnswers, f) : null;
34
+ if (userAnswer) return userAnswer;
38
35
  return ({
39
36
  id: f.id, hasAnswered: false, answers: [], field: f,
40
37
  });
41
38
  });
42
- // eslint-disable-next-line no-underscore-dangle, eqeqeq
43
- const customTextFieldAnswers = fieldRowFields.filter((f) => f.__typename == 'TextField').map((f) => {
44
- if (user) {
45
- const userAnswer = user.customTextFieldAnswers.filter(({ id }) => id === f.id);
46
- if (userAnswer) {
47
- return ({
48
- ...userAnswer[0],
49
- });
50
- }
51
- }
39
+ // eslint-disable-next-line no-underscore-dangle
40
+ const customTextFieldAnswers = fieldRowFields.filter((f) => f.__typename === 'TextField').map((f) => {
41
+ const userAnswer = user ? findUserAnswer(user.customTextFieldAnswers, f) : null;
42
+ if (userAnswer) return userAnswer;
52
43
  return ({
53
44
  id: f.id, hasAnswered: false, value: null, field: f,
54
45
  });