@mindful-web/marko-web-identity-x 1.83.1 → 1.85.2

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.
Files changed (35) hide show
  1. package/CLAUDE.md +302 -0
  2. package/api/mutations/progressive-profile-update.js +29 -0
  3. package/browser/index.js +13 -0
  4. package/browser/progressive-profile.vue +406 -0
  5. package/components/access.marko.js +2 -2
  6. package/components/comment-stream.marko.js +2 -2
  7. package/components/context.marko.js +2 -2
  8. package/components/form-access.marko.js +2 -2
  9. package/components/form-authenticate.marko.js +2 -2
  10. package/components/form-change-email.marko.js +2 -2
  11. package/components/form-login.marko.js +2 -2
  12. package/components/form-logout.marko.js +2 -2
  13. package/components/form-profile.marko.js +2 -2
  14. package/components/form-register.marko.js +2 -2
  15. package/components/google-init.marko.js +2 -2
  16. package/components/google-sign-in-button.marko.js +2 -2
  17. package/components/identify.marko.js +2 -2
  18. package/components/marko.json +16 -0
  19. package/components/non-auth-identify.marko +6 -1
  20. package/components/non-auth-identify.marko.js +3 -3
  21. package/components/progressive-profile-form.marko +127 -0
  22. package/components/progressive-profile-form.marko.js +147 -0
  23. package/components/subscribe.marko.js +2 -2
  24. package/config.js +98 -0
  25. package/package.json +3 -2
  26. package/routes/google.js +5 -0
  27. package/routes/index.js +3 -0
  28. package/routes/progressive-profile.js +296 -0
  29. package/service.js +31 -0
  30. package/utils/add-custom-attributes.js +19 -46
  31. package/utils/build-progressive-profile-payload.js +117 -0
  32. package/utils/get-missing-progressive-fields.js +114 -0
  33. package/utils/get-progressive-form-fields.js +67 -0
  34. package/utils/progressive-profile-cookies.js +120 -0
  35. package/utils/progressive-profile-telemetry.js +148 -0
package/CLAUDE.md CHANGED
@@ -323,6 +323,308 @@ after `setIdentity`. `google-auth-library` is now a base-idx dependency (every s
323
323
  server-only) — accepted because the feature is gated on the client id. The Google OAuth app must
324
324
  list each site origin as an authorized JavaScript origin or the surfaces silently no-show.
325
325
 
326
+ ## Progressive Profiling — added 2026-08
327
+
328
+ Detects an **identified or authenticated** visitor with unanswered demographic attributes, gates
329
+ the article, and asks only the missing questions. The audience that matters is
330
+ *identified-but-not-authenticated* — the `__idx_idt` holder arriving from an email link, the
331
+ majority of who we know. Today those users' form answers go only to an anonymous content-access
332
+ submission and never touch their AppUser record (`form-access.marko:100-105` sets
333
+ `updateProfileOnSubmit = false`); this makes that write real.
334
+
335
+ **Dormant fleet-wide.** `progressiveProfileFieldRows` defaults to `[]`; nothing renders and no
336
+ shared behavior changes until a site declares fields *and* enables the theme's `progressiveProfile`
337
+ site config (Part 2/3, `marko-web-theme-monorail` + the website repos).
338
+
339
+ ### The one hard constraint: attributes only
340
+
341
+ The backing mutation is `progressiveProfileUpdate` on **idx-compat**
342
+ (`mindful-reporting#263`, shipped 2026-08-05). It writes member **attributes** — select, boolean,
343
+ text — and nothing else; built-in/core member fields were cut on review. A built-in column would
344
+ render, accept a value, and then be silently unwritable: the user submits, gets `changed: false`,
345
+ and is re-gated forever. `config.js` therefore **throws at boot** on one, and on three other
346
+ configurations that are each a user-facing trap:
347
+
348
+ | Boot check | Why the runtime failure is worse than a failed deploy |
349
+ | --- | --- |
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. |
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
+ | id missing from a non-empty `activeCustomFieldIds` | `routes/profile.js:88-104` silently discards the answer ⇒ gate loop. |
354
+ | rows configured with no `apiToken` | Every write uses `getOrgUserApiToken()` and the form token is signed from it. |
355
+
356
+ No coverage is lost: every field the fleet-wide impact analysis measured is a select attribute.
357
+
358
+ ### Files
359
+
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.
366
+ - `utils/get-progressive-form-fields.js` — correct, feature-scoped replacement for
367
+ `get-form-custom-fields.js`. Stamps `id` with the **field** id, which is the contract
368
+ `custom-column.vue` reads and what `get-create-user-custom-fields.js` already emits.
369
+ - `utils/build-progressive-profile-payload.js` — marshals the body into mutation variables, plus
370
+ `buildContentSource()`. **Definitions come from server config, never the body**, so a client
371
+ cannot widen what gets written by posting extra rows. Drops an empty select rather than sending
372
+ `optionIds: []`, which the writer reads as *unset* — the one way this feature could destroy data
373
+ despite the fill-blanks-only rule.
374
+ - `routes/progressive-profile.js` — `POST /__idx/progressive-profile` (+ `/dismiss`). A thin proxy;
375
+ it owns *trust*, not writes.
376
+ - `components/progressive-profile-form.marko` (`<marko-web-identity-x-progressive-profile-form>`)
377
+ + `browser/progressive-profile.vue` (`IdentityXProgressiveProfile`, overridable via
378
+ `CustomProgressiveProfileComponent`).
379
+ - `utils/progressive-profile-telemetry.js` — resolves the New Relic agent (injected first, lazy
380
+ fallback) and exposes `createRecorder`, which emits one typed event per submit. See *Telemetry*.
381
+ - `utils/progressive-profile-cookies.js` — the attempts escape valve, the optional dismissal
382
+ suppression, and the 3-day suppression that follows an unverified submit. **Not** bot safeguards;
383
+ that is reCAPTCHA's job, below.
384
+
385
+ ### Gate copy
386
+
387
+ Defaults live in `progressive-profile-form.marko`; a site overrides them per-instance with
388
+ `title` / `callToAction` (threaded through the theme's `progressive-profile-gate.marko`). The
389
+ theme middleware's Joi schema does **not** accept them, so they cannot go in a site's
390
+ `progressiveProfile` config without extending it first — and unknown keys there fail the boot by
391
+ design.
392
+
393
+ The default title names the number of questions (`One` / `Two` / `A few` … `quick question(s),
394
+ then keep reading`), read off `state.missingCount` so it reflects what is actually rendered, not
395
+ what is configured: a two-question site shows the one-question title to a reader who has already
396
+ answered half of it.
397
+
398
+ **Changing any default string is an i18n edit too.** `browser/progressive-profile.vue`'s
399
+ `translate()` map is keyed by the **English source string**, so a reworded default silently falls
400
+ back to English on pt/es sites — no error, no warning. Update the maps in the same change, and add
401
+ one key per count rather than interpolating the number: both translation layers here (the
402
+ app-level `i18n` function and `translate()`) are flat key lookups with no placeholder support.
403
+ Note the two strings translate by different paths — the title runs through `i18n()` *and*
404
+ `translate()`, while `callToAction` is rendered with `v-html` and so never reaches `translate()`.
405
+
406
+ ### What happens server-side of the route
407
+
408
+ Everything, in one Mongo transaction: the re-diff that drops already-filled fields, the attribute
409
+ writes, the `action-history` entries attributed via `context.source = 'PROGRESSIVE_PROFILE'`, and
410
+ one `member-events` document of kind `PROGRESSIVE_PROFILE_SUBMIT`. The route does no
411
+ fetch-then-write of its own, and **no session is ever minted** — a visitor stays merely identified,
412
+ so a bot cannot come away holding one. There is no `actionId` client-side; attribution lives in
413
+ `action-history`.
414
+
415
+ Auth picks the target and the client never does: an authenticated visitor writes as themselves
416
+ (`AppUser` token, `userId` ignored by the resolver), an identified one is written by the org token
417
+ with the **resolved** `targetId` — `body.userId` is never trusted and a mismatch is a 403.
418
+
419
+ Fires `onUserProfileUpdate` only when `changed`, guarded, reported through `onHookError`. That hook
420
+ is **provider-agnostic** — Omeda rapid-identify is the most visible consumer but one of five
421
+ (`marko-web-identity-x-mailchimp`, `marko-web-postup`, plus site-level Braze and ActiveCampaign
422
+ packages). The `profileUpdateHooks` field records `dispatched | skipped | error`, and `dispatched`
423
+ deliberately does **not** mean any hook succeeded: every registrant uses `shouldAwait: false`, and
424
+ `utils/call-hooks-for.js` wraps each promise in `.catch(onHookError)`, so `callHooksFor` returns
425
+ before the hooks run and essentially never rejects. An async failure is reported there and can never
426
+ reach this field; `error` is a *synchronous* dispatch throw only. Deliberately **not** `onLoginLinkSent` / `onAuthenticationSuccess`: those drive
427
+ auto-signup and the GA4 `subscribe` conversion, and this is not a registration.
428
+
429
+ ### Bot protection — verify, then discard the write rather than the visitor
430
+
431
+ The threat is concrete: `marko-web-omeda-identity-x/middleware/set-identity-cookie.js` mints
432
+ `__idx_idt` for a **real person's** AppUser id on any request carrying `oly_enc_id`, so a newsletter
433
+ security scanner following an emailed link browses *as* an identified human and could write to a
434
+ real record. Cloudflare on these sites segregates rather than drops and scores 30–99 reach the
435
+ origin, so there is no upstream to lean on.
436
+
437
+ Verification is the fleet standard — **reCAPTCHA v3 via `@mindful-web/marko-web-recaptcha`**, the
438
+ same package, 0.5 floor and client shape as newsletter signup, contact-us, inquiries and the RigDig
439
+ checkout. Action `progressiveProfileSubmit`, declared client-side and checked server-side so a token
440
+ minted for another form cannot be replayed here.
441
+
442
+ **What differs is the response to a failure: the write is dropped, never the visitor.** The
443
+ submission "succeeds", the content unlocks, and the answers go nowhere. Every other form on the
444
+ fleet returns a hard 400 the visitor can do nothing about, which is right when the worst case is a
445
+ retried newsletter signup. Here the form is the only way past a gate on the article, so rejecting a
446
+ false positive locks a real reader out of content they can otherwise reach — a bigger loss than the
447
+ bot write it prevented. A bot gains an article it could largely have read anonymously, and gains no
448
+ write, which is the only thing actually at stake.
449
+
450
+ An earlier revision escalated to a reCAPTCHA v2 checkbox instead. Removed: it cost a second key pair
451
+ maintained fleet-wide plus a chunk of client code, to solve a problem **nobody has evidence exists**
452
+ — it is unknown whether newsletter scanners submit forms at all, or only load pages. Measure first
453
+ (below); the code is recoverable from `0f35bb1` if the data ever justifies it.
454
+
455
+ **Every failure mode passively accepts** — `low-score`, `no-token`, `bad-token`, `stale-token`,
456
+ `wrong-action` — each recorded separately. `no-token` matters as much as the rest: it is what an ad
457
+ blocker eating `api.js` produces, and the client deliberately **does not disable submit** when the
458
+ script fails to load (as the other forms do) because that would strand a reader behind a gate with
459
+ nothing to click.
460
+
461
+ **One rule for every visitor.** A failed check drops the write whether or not a session is present.
462
+ An earlier revision exempted session holders — `/user/profile` has no captcha either, so a session
463
+ is already sufficient authorization for a profile write elsewhere in this package — but that is a
464
+ user-initiated edit on a page someone navigated to, while this is an interstitial pushed at a
465
+ link-follower, which is exactly what an email scanner hits. Scanners follow magic links, so a
466
+ session is not proof of a human. The exemption also bought nothing: the score is recorded either
467
+ way, so authenticated submits still serve as the known-human control group.
468
+
469
+ **The suppression cookie is mandatory, not a nicety.** A discarded submit leaves the member's record
470
+ unchanged, so the diff would gate them again on the very next article — forever. `__idx_pp_sup`
471
+ (3 days, `progressiveProfile.unverifiedSuppressDays`) stops that and hands the visitor back to the
472
+ content meter, exactly as dismissal does. It is deliberately a **different cookie** from
473
+ `__idx_pp_dis`: "user skipped" and "we threw their answers away" need to be distinguishable in
474
+ Part 2's debug output and in support.
475
+
476
+ **The honest cost, and why the telemetry ships with this and not after.** A false-positive human
477
+ fills the form in good faith, nothing is saved, and three days later they are asked again —
478
+ permanently, invisibly to them *and to us*. The v2 challenge let them rescue themselves; this does
479
+ not. So the score is recorded on **every** submit, passing or not:
480
+ **one custom event per submit, typed by what happened** — `ProgressiveProfileSubmit` (the mutation
481
+ ran), `ProgressiveProfileDiscarded` (passive accept), `ProgressiveProfileRejected` (401/403/400,
482
+ carries `reason`) or `ProgressiveProfileError` (threw, or fell through unrecorded). Shared
483
+ attributes: `contentId`, `ms`, then `recaptcha`/`recaptchaScore` from the verify step and
484
+ `userId`/`auth` once the identity resolves; `Submit` adds `fieldCount`, `changed`,
485
+ `profileUpdateHooks`. Never the submitted answers.
486
+
487
+ Split by type rather than an `outcome` field so each outcome is independently countable and
488
+ alertable — "how many passive submits" is a bare `count(*)`, not a filter. There is deliberately no
489
+ `outcome`/`accepted` attribute; the type carries it, and two encodings drift. (The HTTP response
490
+ still returns `accepted` — that is the dataLayer's contract, unrelated.)
491
+
492
+ **The arithmetic is enforced, not left to discipline.** Each terminal branch records its own type,
493
+ `createRecorder` makes `record` a no-op once anything has fired, and a `finally` backstop catches a
494
+ path that recorded nothing — as `ProgressiveProfileError` with `reason: 'unrecorded'`, so a branch
495
+ added without a call site surfaces in New Relic instead of quietly skewing the mix.
496
+
497
+ **Why a custom event and not transaction attributes — and specifically not because of sampling.**
498
+ Measured before switching: these apps run 165–588 transactions/minute against a ~10k reservoir,
499
+ `Transaction` data is queryable 35 days back, and `idxGoogleStep` attributes land and query fine
500
+ today. Every New Relic destination is reservoir-backed anyway (`transaction_events`,
501
+ `custom_insights_events`, `application_logging.forwarding`), so there is no unsampled path to move
502
+ to; `recordLogEvent` would have been worst of all here, since every Log record on this account
503
+ arrives via the HTTP Log API with no `entity.guid` — no APM agent forwards application logs, so it
504
+ would likely have no-opped. The real reasons: the decision rule needs `histogram(recaptchaScore)`
505
+ and a dedicated event type is the right shape for it, `FROM Transaction WHERE name LIKE '%…%'` is a
506
+ string match a route rename silently breaks, and the measurement stops depending on `Transaction`
507
+ retention staying where someone set it. **Do not "fix" this back to `addCustomAttributes` on the
508
+ grounds that sampling was never a problem — it wasn't.**
509
+
510
+ **Deliberately unsampled**, unlike `MindfulApiCall`, which needed a hand-written sampler at ~40M
511
+ events/month. This is one row per gate submission: `count(*)` is the true count and there is no
512
+ `sampleRate` attribute.
513
+
514
+ The agent comes from `req.app.locals.newrelic` when a site passed one to `startServer`
515
+ (mindful-web#314), falling back to the shared lazy resolver in `@mindful-web/utils` — the option is
516
+ new and **no site passes it yet**, so without the fallback this would go dark fleet-wide. That
517
+ fallback is also why `utils/add-custom-attributes.js` is now a one-line delegation to
518
+ `newrelicAgent.addCustomAttributes`: it used to carry its own line-for-line copy of the same agent
519
+ resolution. `routes/google.js` is unchanged and keeps its step attributes on the transaction, where
520
+ `idxGoogleStep` riding on `TransactionError` is what makes a 500 self-describing.
521
+
522
+ Capturing the score required a small addition to the shared package: `verifyToken()` returns
523
+ `{ success, score, action, hostname, errorCodes }` instead of throwing, and `validateToken` is now a
524
+ thin policy layer over it with an unchanged contract. Reach for `verifyToken` when a caller needs to
525
+ *decide*; `validateToken` remains right for a form that should just refuse. **Two traps if you touch
526
+ it:** `science-medicine-group-websites`'s preference center echoes `e.message` verbatim to the
527
+ browser, so those three strings are user-facing copy; and `fusable-websites/packages/payfabric`
528
+ catches on `error.code`, which `http-errors` does not set, so a rejection there surfaces as a 500 —
529
+ adding a `.code` would silently flip it to 400. There is also a live regression trap inside
530
+ `validateToken` itself, commented at the call site: the original compared `undefined < minimumScore`
531
+ against the raw body, so a success carrying no score *passed*; `verifyToken` normalizes that to
532
+ `null`, and `null < 0.5` is true.
533
+
534
+ **The decision rule.** Run these after launch (`~/.newrelic/nrql`, account 2723345) and act on them;
535
+ a rejection rate is unactionable without the distribution behind it.
536
+
537
+ ```sql
538
+ -- The mix. Rates span types with a comma-union, which NRQL supports natively.
539
+ SELECT count(*) FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded,
540
+ ProgressiveProfileRejected, ProgressiveProfileError FACET eventType() SINCE 1 day ago
541
+
542
+ -- Passive submits, and why.
543
+ SELECT count(*) FROM ProgressiveProfileDiscarded FACET recaptcha SINCE 1 day ago
544
+
545
+ -- The discard rate the rollout table keys on.
546
+ SELECT percentage(count(*), WHERE eventType() = 'ProgressiveProfileDiscarded')
547
+ FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago
548
+
549
+ SELECT histogram(recaptchaScore, 1.0, 10)
550
+ FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago
551
+
552
+ -- Authenticated submits are the known-human control group: if their p5 sits below the
553
+ -- threshold, the threshold is wrong, not the traffic.
554
+ SELECT percentile(recaptchaScore, 5, 25, 50)
555
+ FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded FACET auth SINCE 1 week ago
556
+ ```
557
+
558
+ | Discard rate | Score shape | Action |
559
+ | --- | --- | --- |
560
+ | < 2% | any | Done. Passive accept is permanent. |
561
+ | 2–10% | clustered below ~0.2 | Bots. Keep passive accept. |
562
+ | 2–10% | clustered 0.3–0.5 | Lower `recaptchaMinimumScore`, re-measure. Far cheaper than a challenge. |
563
+ | > 10% | any | Build the v2 escalation — `0f35bb1`. |
564
+
565
+ **What actually bounds the damage** is not on this route and never was: the mutation re-diffs inside
566
+ its own transaction and can only ever fill a field that was already empty, so nothing that gets past
567
+ reCAPTCHA can destroy data. Every write also lands in `action-history` with its `input`/`result`, so
568
+ a bad one is findable and reversible from one collection.
569
+
570
+ **Escape valve.** `progressive-profile-cookies.js` tracks gate renders; the theme middleware
571
+ increments and releases the content past `maxAttemptsPerUser`, and this route clears the counter on
572
+ any success. The known cause of an unclearable gate is gone structurally, but a gate that can trap a
573
+ real person is worse than no gate.
574
+
575
+ ### Telemetry & analytics
576
+
577
+ One custom event per submit, typed by outcome — `ProgressiveProfileSubmit` / `Discarded` /
578
+ `Rejected` / `Error`. Shared attributes `recaptcha`, `recaptchaScore`, `auth` (`org|user`; `FACET`
579
+ it to see how much traffic is identified-but-not-authenticated, the population the feature exists
580
+ for), `userId`, `contentId`, `ms`; `Submit` adds `changed`, `fieldCount`, `profileUpdateHooks`. See *Bot protection* above for why this is an event rather than transaction
581
+ attributes, and for the decision rule it feeds. Operational only — **submission counts come from
582
+ `member-events`.**
583
+
584
+ The one thing still on the transaction is `onHookError`'s payload
585
+ (`idxProgressiveProfileStep`, `idxUserId`), which rides on `noticeError` — a separate seam, and the
586
+ right place for it: a non-fatal hook failure belongs next to the error it describes.
587
+
588
+ The dataLayer bridge and the p1events bridge are independent subscribers to the same EventBus, so
589
+ what reaches each is decided separately:
590
+
591
+ - **p1events: form views only.** A dedicated branch in `marko-web-p1-events/browser/index.js`
592
+ (`Identity / View / Progressive Profile`, top-level content `entity`, `props` carrying
593
+ `audience`, `fieldCount`, `fieldKeys`). No submit event: `member-events` is written by the
594
+ mutation that did the work, inside its transaction, so no event means no write. Nothing is added
595
+ to `identifyOnSubmit` — the session is already identified before the form renders.
596
+
597
+ **Consequence of passive accept:** a discarded submit writes no `member-events` row, so the
598
+ abandonment funnel (view identities minus `PROGRESSIVE_PROFILE_SUBMIT`) counts it as abandonment.
599
+ Arguably correct — no data was collected — but "never submitted" and "submitted and we threw it
600
+ away" are different populations, and only the New Relic `unverified` count separates them.
601
+ - **GA4/dataLayer keeps a submit event**, carrying server-truth `changed` so the conversion trigger
602
+ can filter on `changed === true`. A discarded (unverified) submit reports `changed: false`, so it
603
+ is excluded from completions with **no GTM change**; the accompanying `accepted` value
604
+ (`written|unverified`) just makes discards separately countable. GA4 will read **lower** than
605
+ `member-events` (blockers, bot filters, unload races); the direction is always undercount.
606
+ Reconciling them is a watchlist item, not a bug.
607
+
608
+ **Trap:** `global-event-emitter.js` assigns `additionalEventData` *after* spreading `data`,
609
+ clobbering anything the caller passed under that key — which is why `access.vue`'s `ent:` never
610
+ reaches the bridge. The content entity is passed as `emit`'s third argument instead.
611
+
612
+ Submitted **answer values are never tracked** anywhere — `fieldKeys` and the member-event's
613
+ `fieldIds` record which questions were asked, nothing more.
614
+
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.
627
+
326
628
  ## Fleet / rollout
327
629
 
328
630
  Not tracked in-repo. The Google-auth architecture change + fleet rollout plan lives outside the repo
@@ -0,0 +1,29 @@
1
+ const gql = require('graphql-tag');
2
+ const userFragment = require('../fragments/active-user');
3
+
4
+ /**
5
+ * The single consolidated write behind the progressive-profile gate.
6
+ *
7
+ * Everything happens server-side of this boundary, in one Mongo transaction: the re-diff that
8
+ * drops already-answered fields, the attribute writes, the `action-history` entries attributed to
9
+ * `PROGRESSIVE_PROFILE`, and the `PROGRESSIVE_PROFILE_SUBMIT` member-event that is the
10
+ * authoritative submission record. The route does no fetch-then-write of its own.
11
+ *
12
+ * Accepts either auth mode: an `AppUser` token writes to its own member and `userId` is ignored;
13
+ * an `OrgUserApiToken` writes to `input.userId`, which is the common case here — the gate's
14
+ * primary audience is identified-but-not-authenticated. **No session is ever minted**, so a
15
+ * visitor stays merely identified and a bot cannot come away holding one.
16
+ */
17
+ module.exports = gql`
18
+ mutation ProgressiveProfileUpdate($input: ProgressiveProfileUpdateMutationInput!) {
19
+ progressiveProfileUpdate(input: $input) {
20
+ ok
21
+ changed
22
+ appUser {
23
+ ...ActiveUserFragment
24
+ }
25
+ }
26
+ }
27
+
28
+ ${userFragment}
29
+ `;
package/browser/index.js CHANGED
@@ -9,6 +9,7 @@ const Download = () => import(/* webpackChunkName: "identity-x-download" */ './d
9
9
  const Logout = () => import(/* webpackChunkName: "identity-x-logout" */ './logout.vue');
10
10
  const Login = () => import(/* webpackChunkName: "identity-x-login" */ './login.vue');
11
11
  const Profile = () => import(/* webpackChunkName: "identity-x-profile" */ './profile.vue');
12
+ const ProgressiveProfile = () => import(/* webpackChunkName: "identity-x-progressive-profile" */ './progressive-profile.vue');
12
13
  const CommentStream = () => import(/* webpackChunkName: "identity-x-comment-stream" */ './comments/stream.vue');
13
14
 
14
15
  const $graphql = createGraphqlClient({ uri: '/__graphql' });
@@ -25,6 +26,7 @@ export default (Browser, {
25
26
  CustomLoginComponent,
26
27
  CustomLogoutComponent,
27
28
  CustomProfileComponent,
29
+ CustomProgressiveProfileComponent,
28
30
  } = {}) => {
29
31
  const AccessComponent = CustomAccessComponent || Access;
30
32
  const AuthenticateComponent = CustomAuthenticateComponent || Authenticate;
@@ -35,6 +37,7 @@ export default (Browser, {
35
37
  const LoginComponent = CustomLoginComponent || Login;
36
38
  const LogoutComponent = CustomLogoutComponent || Logout;
37
39
  const ProfileComponent = CustomProfileComponent || Profile;
40
+ const ProgressiveProfileComponent = CustomProgressiveProfileComponent || ProgressiveProfile;
38
41
 
39
42
  window.IdentityX = new IdentityX();
40
43
 
@@ -48,6 +51,7 @@ export default (Browser, {
48
51
  Browser.register('IdentityXLogin', LoginComponent, { provide: { EventBus } });
49
52
  Browser.register('IdentityXLogout', LogoutComponent, { provide: { EventBus } });
50
53
  Browser.register('IdentityXProfile', ProfileComponent, { provide: { EventBus } });
54
+ Browser.register('IdentityXProgressiveProfile', ProgressiveProfileComponent, { provide: { EventBus } });
51
55
 
52
56
  // Ensure the client-side IdX context is refreshed when the authentication event occurs
53
57
  EventBus.$on('identity-x-authenticated', () => window.IdentityX.refreshContext());
@@ -66,6 +70,7 @@ export default (Browser, {
66
70
  'identity-x-login-mounted',
67
71
  'identity-x-logout-mounted',
68
72
  'identity-x-profile-mounted',
73
+ 'identity-x-progressive-profile-mounted',
69
74
  // Actions/submissions
70
75
  'identity-x-access-submitted',
71
76
  'identity-x-authenticated',
@@ -79,6 +84,12 @@ export default (Browser, {
79
84
  'identity-x-login-link-sent',
80
85
  'identity-x-logout',
81
86
  'identity-x-profile-updated',
87
+ // GA4 keeps a submit conversion for progressive profiling even though p1events does not:
88
+ // campaign/source attribution is impossible from the server-side `member-events` record.
89
+ // The conversion trigger must filter on `changed === true` so a no-op submit is not counted
90
+ // as a profile completion. Expect GA4 to read LOWER than `member-events` -- blockers, bot
91
+ // filters and unload races drop dataLayer pushes but not server writes.
92
+ 'identity-x-progressive-profile-submitted',
82
93
  // Errors
83
94
  'identity-x-access-errored',
84
95
  'identity-x-authenticate-errored',
@@ -90,6 +101,8 @@ export default (Browser, {
90
101
  'identity-x-login-errored',
91
102
  'identity-x-logout-errored',
92
103
  'identity-x-profile-errored',
104
+ 'identity-x-progressive-profile-errored',
105
+ 'identity-x-progressive-profile-dismissed',
93
106
  ].forEach((event) => {
94
107
  EventBus.$on(event, (args) => {
95
108
  if (!window.IdentityX) return;