@mindful-web/marko-web-identity-x 1.79.6 → 1.79.8

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
@@ -105,7 +105,55 @@ conflict-guarded; non-fatal via `config.get('onHookError')`) → `impersonateApp
105
105
  `onAuthenticationSuccess`, same order/hooks as email-link so Omeda rapid-identify + subscription
106
106
  sync run identically. `profile-gate` computes `requiresUserInput` by checking required fields are
107
107
  non-empty (deliberately not `forceProfileReVerification`). Responds `{ ok, requiresUserInput,
108
- redirectTo, user, entity, … }`; the client redirects to `/user/profile` when input is required.
108
+ redirectTo, user, entity, additionalEventData, … }`; the client redirects to `/user/profile` when
109
+ input is required.
110
+
111
+ **`additionalEventData` and the auto-signup union.** Tenant Omeda formatters *assign*
112
+ `additionalEventData.autoSignups` in place, and that object is the only channel by which the browser
113
+ learns a subscription was created (see analytics below). Where they assign it varies, and three
114
+ repos do it twice:
115
+
116
+ | Writes `autoSignups` in | Repos |
117
+ | --- | --- |
118
+ | `onLoginLinkSentFormatter` only | ab-media, allured, encore360, pmmi |
119
+ | `onAuthenticationSuccessFormatter` only | fusable, cox-matthews |
120
+ | **Both** | industrial (both pkgs), ironmarkets, watt |
121
+
122
+ So the route gives **each hook its own object** and merges the two afterwards, taking the union of
123
+ `autoSignups` deduped by `productId`. Sharing one reference (the obvious move, and what
124
+ `routes/authenticate.js` does — it only ever calls one hook) would let the second hook's assignment
125
+ clobber the first, silently dropping a whole set of subscribe events on the both-hook repos. In the
126
+ email-link flow those hooks fire on **separate requests** (login vs authenticate) and each reports
127
+ its own set, so the union is what preserves existing behaviour. Do **not** "simplify" this back to a
128
+ shared object.
129
+
130
+ `requiresUserInput` goes to `onAuthenticationSuccess` **only**: the onLoginLinkSent formatters read a
131
+ truthy value as "skip the Omeda subscription lookup" and then opt the user into every configured
132
+ deployment type without deduping, firing a phantom auto-signup for an existing Omeda customer who
133
+ merely lacks an idx required field. The email-link flow never exposes it there either —
134
+ `routes/login.js` 400s before any hook runs.
135
+
136
+ `idxAutoSignupCount` rides on the transaction attributes so "sign-in worked, Omeda updated, GA4
137
+ recorded nothing" — the exact shape of the July 2026 bug — is a visible number rather than something
138
+ a stakeholder finds in a report weeks later.
139
+
140
+ **Analytics parity (`components/google-init.marko`).** The Vue flows get `dataLayer` + p1events for
141
+ free from the EventBus bridge in `browser/index.js`; the One-Tap callback is plain inline JS on a
142
+ `<script>` tag, so it has to emit the same events by hand. Three things it must keep in step with
143
+ `browser/authenticate.vue` + `browser/mixins/*`:
144
+ - **`identity-x-authenticated`** — payload mirrors `global-event-emitter.js`, which sets
145
+ `actionSource`, `loginSource` **and** `source` to the same value. GTM variables in the wild read
146
+ whichever one they were written against; emitting only `actionSource` silently breaks the rest.
147
+ - **`identity-x-auto-signup`** — one event per `additionalEventData.autoSignups` entry, shape
148
+ `{ entity, ...autoSignup, event }` per `global-auto-signup-event-emitter.js`. **This — not
149
+ `identity-x-authenticated` — is what the sites' GA4 "subscribe" conversion triggers on.** The
150
+ Omeda opt-in happens server-side either way, so a missing event here looks like "One Tap doesn't
151
+ subscribe anyone" when the subscription exists and only the tracking is gone. Also mirrored to
152
+ p1events as `Subscription / Subscribe`.
153
+ - **Navigation is deferred ~300ms** after the pushes. The email-link flow navigates in the same tick
154
+ but does so from a fully-loaded `/user/authenticate`; One Tap can resolve far earlier in a page's
155
+ life, before GTM has dispatched. `window.dataLayer` is also **assigned, not guarded** (`= … || []`)
156
+ so a sign-in that beats a deferred container still queues its events instead of dropping them.
109
157
 
110
158
  **Tags** (`components/marko.json`, config read from `req.identityX.config`):
111
159
  - `<marko-web-identity-x-google-init>` — loads the GSI client + defines `window.handleGoogleOneTapCredential`;
@@ -119,6 +167,35 @@ login/authenticate pages get the button with **no per-site edit**. For the site-
119
167
  document layout, gated on `!req.identityX.token`. The button tag can also be placed in a content
120
168
  meter / gate.
121
169
 
170
+ **Telemetry.** Two seams. Neither needs site configuration:
171
+ - `config.addRequestAttributes` — called with a flat object as the route progresses
172
+ (`idxGoogleStep`, `idxUserId`, `idxCreatedNewUser`, `idxRequiresUserInput`, `idxGoogleSurface`,
173
+ `idxGoogleMissingFieldsBehavior`, `idxAutoSignupCount`). **Defaults to `utils/add-custom-attributes.js`**, which tags
174
+ the current New Relic transaction, so the attributes ride on the `Transaction` *and* any
175
+ `TransactionError` it produces — a 500 says which step failed instead of being a bare GraphQL
176
+ message. That default resolves the agent **only if the app already loaded it** (checks
177
+ `require.cache`, never requires it cold — requiring `newrelic` would *start* an agent on sites
178
+ that don't use it), so it arrives fleet-wide on a dep bump and is inert everywhere else. Pass a
179
+ function to override, or a no-op to disable. Deliberately **not** `noticeError`: that records a
180
+ second error next to the one the error handler already captures and doubles the counts.
181
+ - `config.onHookError` — non-fatal errors, now called as `(error, customAttributes)` to match
182
+ `newrelic.noticeError`. The Google `sub` linkage failures report through it with their step and
183
+ user id, which keeps them distinguishable from fatal errors on the same route (they arrive on a
184
+ 200).
185
+
186
+ Client-side, `fail()` fires a p1events `Identity / Authenticate Failed` event mirroring the success
187
+ event. There is no New Relic Browser agent on the monorail sites, so without it a client-side
188
+ failure reaches no backend at all. The originating surface (`button` | `prompt`, from GIS
189
+ `select_by`) is posted to the route as `body.surface` — both surfaces otherwise share a route, a
190
+ `loginSource` and an impersonation method, so nothing server-side could tell them apart.
191
+
192
+ **Failure handling.** All client-side failures funnel through `fail()` in `google-init.marko`: it
193
+ always `console.error`s, writes to `#google-sign-in-error` when that element is on the page (it is
194
+ rendered by `google-sign-in-button.marko`, so it exists only where the button does), and otherwise
195
+ — the site-wide floating prompt case — redirects to the login endpoint with `?redirectTo=`. Never
196
+ let a failure be a silent no-op: GIS closes the prompt on its own, so the user reads "nothing
197
+ happened" as success and only discovers they're anonymous on the next page load.
198
+
122
199
  **CSS.** `.google-sign-in` styles ship in `marko-web-theme-monorail/scss/components/_identity-x.scss`
123
200
  (with the rest of the auth CSS) — no separate import.
124
201
 
@@ -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.79.6/components/access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/access.marko",
56
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/comment-stream.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/comment-stream.marko",
94
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/context.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/context.marko",
52
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-access.marko",
182
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-authenticate.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-authenticate.marko",
84
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-change-email.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-change-email.marko",
62
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-login.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-login.marko",
95
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-logout.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-logout.marko",
32
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-profile.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-profile.marko",
87
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-register.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/form-register.marko",
28
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/components/form-register.marko",
29
29
  tags: [
30
30
  "./form-login.marko"
31
31
  ]
@@ -2,6 +2,7 @@ $ const { req } = out.global;
2
2
  $ const config = req.identityX && req.identityX.config;
3
3
  $ const { clientId, autoPrompt } = config ? config.getAsObject('googleAuth') : {};
4
4
  $ const callbackName = 'handleGoogleOneTapCredential';
5
+ $ const loginUrl = config ? config.getEndpointFor('login') : '/user/login';
5
6
  $ const initScript = `
6
7
  (function() {
7
8
  window.${callbackName} = function(response) {
@@ -12,24 +13,97 @@ $ const initScript = `
12
13
  // GIS 'select_by' distinguishes the surface: a 'btn*' value means the
13
14
  // rendered "Sign in with Google" button, 'user*'/'auto' means the One-Tap
14
15
  // prompt. Used to label the p1events authentication event accordingly.
15
- var eventLabel = String(response.select_by || '').indexOf('btn') === 0
16
- ? 'Google Sign In'
17
- : 'Google One Tap';
16
+ var isButton = String(response.select_by || '').indexOf('btn') === 0;
17
+ var eventLabel = isButton ? 'Google Sign In' : 'Google One Tap';
18
+
19
+ // Every failure path routes through here. The site-wide floating One-Tap
20
+ // prompt renders outside any form, so there is no '#google-sign-in-error'
21
+ // element on the page — previously that meant a failed sign-in did nothing
22
+ // at all: GIS closed the prompt, the user assumed success, and only found
23
+ // out they were still anonymous on the next page load. Always log, and
24
+ // fall back to the login page when there's nowhere to show the message.
25
+ var authenticated = false;
26
+ function fail(message, detail) {
27
+ if (window.console && window.console.error) {
28
+ window.console.error('[identity-x google-auth] ' + message, detail);
29
+ }
30
+ // There is no New Relic Browser agent on these sites, so a client-side
31
+ // failure reaches no backend at all unless we send one. p1events is
32
+ // already on every page -- mirror the success event so failed sign-ins
33
+ // are countable next to successful ones rather than invisible.
34
+ if (window.p1events) {
35
+ window.p1events('track', {
36
+ category: 'Identity',
37
+ action: 'Authenticate Failed',
38
+ label: eventLabel,
39
+ props: {
40
+ actionSource: 'google-one-tap',
41
+ surface: isButton ? 'button' : 'prompt',
42
+ message: message,
43
+ status: detail && detail.status ? detail.status : null,
44
+ },
45
+ });
46
+ }
47
+ // The session cookie is already set — a throw in the post-success
48
+ // analytics/redirect code must not bounce an authenticated user to login.
49
+ if (authenticated) return;
50
+ if (errorEl) {
51
+ errorEl.textContent = message;
52
+ errorEl.style.display = 'block';
53
+ return;
54
+ }
55
+ var returnTo = window.location.pathname + window.location.search;
56
+ window.location.href = ${JSON.stringify(loginUrl)} + '?redirectTo=' + encodeURIComponent(returnTo);
57
+ }
18
58
 
19
59
  fetch('/__idx/google', {
20
60
  method: 'POST',
21
61
  headers: { 'Content-Type': 'application/json' },
22
- body: JSON.stringify({ credential: response.credential, redirectTo: redirectTo }),
62
+ body: JSON.stringify({
63
+ credential: response.credential,
64
+ redirectTo: redirectTo,
65
+ // Lets the server tag its telemetry with the originating surface --
66
+ // both post here and are otherwise indistinguishable server-side.
67
+ surface: isButton ? 'button' : 'prompt',
68
+ }),
23
69
  })
24
- .then(function(res) { return res.json(); })
25
- .then(function(data) {
70
+ // Parse defensively: a 5xx from the idx router returns
71
+ // { error, status, message } (no 'ok'), and an upstream proxy can return
72
+ // HTML that would otherwise blow up res.json() into the generic catch.
73
+ .then(function(res) {
74
+ return res.text().then(function(text) {
75
+ var parsed = {};
76
+ try { parsed = JSON.parse(text); } catch (e) { /* non-JSON response */ }
77
+ return { status: res.status, redirected: res.redirected, url: res.url, data: parsed };
78
+ });
79
+ })
80
+ .then(function(result) {
81
+ var data = result.data;
26
82
  if (!data.ok) {
27
- if (errorEl) {
28
- errorEl.textContent = data.error || 'Google sign-in failed. Please try again.';
29
- errorEl.style.display = 'block';
83
+ // A 301/302 on this POST (host canonicalization — apex -> www, or
84
+ // http -> https) is re-issued by the browser as a GET with the body
85
+ // dropped. The idx router only registers POST /__idx/google, so the
86
+ // redirected request falls through to a 404 and the sign-in is lost.
87
+ // Call that out by name rather than reporting a generic failure.
88
+ if (result.redirected) {
89
+ fail(
90
+ 'Google sign-in failed: the request was redirected to ' + result.url
91
+ + ', which turns it into a GET and drops the credential. '
92
+ + 'Serve this page from the canonical host.',
93
+ { status: result.status }
94
+ );
95
+ return;
30
96
  }
97
+ fail(
98
+ data.error || data.message || 'Google sign-in failed. Please try again.',
99
+ { status: result.status }
100
+ );
31
101
  return;
32
102
  }
103
+ authenticated = true;
104
+ var eventData = data.additionalEventData || {};
105
+ var autoSignups = eventData.autoSignups || [];
106
+
33
107
  if (window.p1events) {
34
108
  // De-anonymize the session to the app user, then record the
35
109
  // authentication — mirrors the identity-x conversion events.
@@ -40,36 +114,92 @@ $ const initScript = `
40
114
  label: eventLabel,
41
115
  props: { idxEntity: data.entity, actionSource: 'google-one-tap' },
42
116
  });
117
+ // One 'Subscription / Subscribe' event per newsletter the server
118
+ // opted this user into, matching what browser/index.js emits for the
119
+ // 'identity-x-auto-signup' bus event in the email-link flow.
120
+ for (var pi = 0; pi < autoSignups.length; pi += 1) {
121
+ window.p1events('track', {
122
+ category: 'Subscription',
123
+ action: 'Subscribe',
124
+ props: {
125
+ idxEntity: data.entity,
126
+ actionSource: autoSignups[pi].actionSource || data.loginSource,
127
+ subscriptionEntity: autoSignups[pi].subscriptionEntity,
128
+ },
129
+ });
130
+ }
43
131
  }
44
- // Push to dataLayer for GTM/GA4 integration, matching the shape
45
- // produced by the standard email-link authenticate flow so existing
46
- // GTM triggers/variables pick it up. The actionSource value
47
- // ('google-one-tap') can be used to populate subscription_source
48
- // or other conversion metadata in GA4.
49
- if (window.dataLayer) {
50
- window.dataLayer.push({
132
+
133
+ // Push to dataLayer for GTM/GA4. Assign rather than guard: GTM can be
134
+ // deferred behind the script loader, and a One-Tap sign-in that lands
135
+ // before the container initializes would otherwise drop its events
136
+ // silently. Pushing into a plain array is safe — GTM replays whatever
137
+ // is already there when it loads.
138
+ window.dataLayer = window.dataLayer || [];
139
+
140
+ // Mirror the payload the standard email-link authenticate flow builds
141
+ // (browser/authenticate.vue + mixins/global-event-emitter.js) so GTM
142
+ // triggers and dataLayer variables resolve identically. That flow sets
143
+ // actionSource, loginSource AND source to the same value; a variable
144
+ // reading any one of the three has to work here too.
145
+ var idxAuthPayload = {
146
+ event: 'identity-x-authenticated',
147
+ 'identity-x': {
51
148
  event: 'identity-x-authenticated',
52
- 'identity-x': {
53
- event: 'identity-x-authenticated',
54
- id: data.user.id,
55
- email: data.user.email,
56
- entity: data.entity,
57
- actionSource: data.loginSource,
58
- },
149
+ ok: true,
150
+ applicationId: data.applicationId,
151
+ user: data.user,
152
+ id: data.user.id,
153
+ email: data.user.email,
154
+ verifiedCount: data.user.verifiedCount,
155
+ entity: data.entity,
156
+ requiresUserInput: data.requiresUserInput,
157
+ createdNewUser: eventData.createdNewUser,
158
+ additionalEventData: eventData,
159
+ actionSource: data.loginSource,
160
+ loginSource: data.loginSource,
161
+ source: data.loginSource,
162
+ },
163
+ };
164
+ window.dataLayer.push(idxAuthPayload);
165
+
166
+ // The 'subscribe' conversion keys off 'identity-x-auto-signup', not
167
+ // 'identity-x-authenticated' — one event per newsletter product the
168
+ // Omeda hook subscribed the user to. Shape matches
169
+ // mixins/global-auto-signup-event-emitter.js -> browser/index.js:
170
+ // { entity, ...autoSignup, event }.
171
+ for (var i = 0; i < autoSignups.length; i += 1) {
172
+ var signupData = { entity: data.entity };
173
+ var autoSignup = autoSignups[i];
174
+ for (var key in autoSignup) {
175
+ if (Object.prototype.hasOwnProperty.call(autoSignup, key)) {
176
+ signupData[key] = autoSignup[key];
177
+ }
178
+ }
179
+ signupData.event = 'identity-x-auto-signup';
180
+ window.dataLayer.push({
181
+ event: 'identity-x-auto-signup',
182
+ 'identity-x': signupData,
59
183
  });
60
184
  }
185
+
186
+ var href;
61
187
  if (data.requiresUserInput) {
62
188
  var destination = data.redirectTo || window.location.pathname;
63
- window.location.href = '/user/profile?returnTo=' + encodeURIComponent(destination);
189
+ href = '/user/profile?returnTo=' + encodeURIComponent(destination);
64
190
  } else {
65
- window.location.href = data.redirectTo || window.location.href;
191
+ href = data.redirectTo || window.location.href;
66
192
  }
193
+ // Give GTM a moment to dispatch the tags above before tearing down the
194
+ // document. The email-link flow navigates in the same tick, but it does
195
+ // so from /user/authenticate after a full page load, where the container
196
+ // is long since warm; a One-Tap sign-in can resolve much earlier in a
197
+ // page's life. A short delay is imperceptible next to the sign-in
198
+ // round-trip and cheaper than losing the conversion.
199
+ window.setTimeout(function() { window.location.href = href; }, 300);
67
200
  })
68
- .catch(function() {
69
- if (errorEl) {
70
- errorEl.textContent = 'An error occurred during Google sign-in. Please try again.';
71
- errorEl.style.display = 'block';
72
- }
201
+ .catch(function(e) {
202
+ fail('An error occurred during Google sign-in. Please try again.', e);
73
203
  });
74
204
  };
75
205
  })();
@@ -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.79.6/components/google-init.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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");
@@ -18,6 +18,8 @@ function render(input, out, __component, component, state) {
18
18
 
19
19
  const callbackName = 'handleGoogleOneTapCredential';
20
20
 
21
+ const loginUrl = config ? config.getEndpointFor('login') : '/user/login';
22
+
21
23
  const initScript = `
22
24
  (function() {
23
25
  window.${callbackName} = function(response) {
@@ -28,24 +30,97 @@ function render(input, out, __component, component, state) {
28
30
  // GIS 'select_by' distinguishes the surface: a 'btn*' value means the
29
31
  // rendered "Sign in with Google" button, 'user*'/'auto' means the One-Tap
30
32
  // prompt. Used to label the p1events authentication event accordingly.
31
- var eventLabel = String(response.select_by || '').indexOf('btn') === 0
32
- ? 'Google Sign In'
33
- : 'Google One Tap';
33
+ var isButton = String(response.select_by || '').indexOf('btn') === 0;
34
+ var eventLabel = isButton ? 'Google Sign In' : 'Google One Tap';
35
+
36
+ // Every failure path routes through here. The site-wide floating One-Tap
37
+ // prompt renders outside any form, so there is no '#google-sign-in-error'
38
+ // element on the page — previously that meant a failed sign-in did nothing
39
+ // at all: GIS closed the prompt, the user assumed success, and only found
40
+ // out they were still anonymous on the next page load. Always log, and
41
+ // fall back to the login page when there's nowhere to show the message.
42
+ var authenticated = false;
43
+ function fail(message, detail) {
44
+ if (window.console && window.console.error) {
45
+ window.console.error('[identity-x google-auth] ' + message, detail);
46
+ }
47
+ // There is no New Relic Browser agent on these sites, so a client-side
48
+ // failure reaches no backend at all unless we send one. p1events is
49
+ // already on every page -- mirror the success event so failed sign-ins
50
+ // are countable next to successful ones rather than invisible.
51
+ if (window.p1events) {
52
+ window.p1events('track', {
53
+ category: 'Identity',
54
+ action: 'Authenticate Failed',
55
+ label: eventLabel,
56
+ props: {
57
+ actionSource: 'google-one-tap',
58
+ surface: isButton ? 'button' : 'prompt',
59
+ message: message,
60
+ status: detail && detail.status ? detail.status : null,
61
+ },
62
+ });
63
+ }
64
+ // The session cookie is already set — a throw in the post-success
65
+ // analytics/redirect code must not bounce an authenticated user to login.
66
+ if (authenticated) return;
67
+ if (errorEl) {
68
+ errorEl.textContent = message;
69
+ errorEl.style.display = 'block';
70
+ return;
71
+ }
72
+ var returnTo = window.location.pathname + window.location.search;
73
+ window.location.href = ${JSON.stringify(loginUrl)} + '?redirectTo=' + encodeURIComponent(returnTo);
74
+ }
34
75
 
35
76
  fetch('/__idx/google', {
36
77
  method: 'POST',
37
78
  headers: { 'Content-Type': 'application/json' },
38
- body: JSON.stringify({ credential: response.credential, redirectTo: redirectTo }),
79
+ body: JSON.stringify({
80
+ credential: response.credential,
81
+ redirectTo: redirectTo,
82
+ // Lets the server tag its telemetry with the originating surface --
83
+ // both post here and are otherwise indistinguishable server-side.
84
+ surface: isButton ? 'button' : 'prompt',
85
+ }),
39
86
  })
40
- .then(function(res) { return res.json(); })
41
- .then(function(data) {
87
+ // Parse defensively: a 5xx from the idx router returns
88
+ // { error, status, message } (no 'ok'), and an upstream proxy can return
89
+ // HTML that would otherwise blow up res.json() into the generic catch.
90
+ .then(function(res) {
91
+ return res.text().then(function(text) {
92
+ var parsed = {};
93
+ try { parsed = JSON.parse(text); } catch (e) { /* non-JSON response */ }
94
+ return { status: res.status, redirected: res.redirected, url: res.url, data: parsed };
95
+ });
96
+ })
97
+ .then(function(result) {
98
+ var data = result.data;
42
99
  if (!data.ok) {
43
- if (errorEl) {
44
- errorEl.textContent = data.error || 'Google sign-in failed. Please try again.';
45
- errorEl.style.display = 'block';
100
+ // A 301/302 on this POST (host canonicalization — apex -> www, or
101
+ // http -> https) is re-issued by the browser as a GET with the body
102
+ // dropped. The idx router only registers POST /__idx/google, so the
103
+ // redirected request falls through to a 404 and the sign-in is lost.
104
+ // Call that out by name rather than reporting a generic failure.
105
+ if (result.redirected) {
106
+ fail(
107
+ 'Google sign-in failed: the request was redirected to ' + result.url
108
+ + ', which turns it into a GET and drops the credential. '
109
+ + 'Serve this page from the canonical host.',
110
+ { status: result.status }
111
+ );
112
+ return;
46
113
  }
114
+ fail(
115
+ data.error || data.message || 'Google sign-in failed. Please try again.',
116
+ { status: result.status }
117
+ );
47
118
  return;
48
119
  }
120
+ authenticated = true;
121
+ var eventData = data.additionalEventData || {};
122
+ var autoSignups = eventData.autoSignups || [];
123
+
49
124
  if (window.p1events) {
50
125
  // De-anonymize the session to the app user, then record the
51
126
  // authentication — mirrors the identity-x conversion events.
@@ -56,36 +131,92 @@ function render(input, out, __component, component, state) {
56
131
  label: eventLabel,
57
132
  props: { idxEntity: data.entity, actionSource: 'google-one-tap' },
58
133
  });
134
+ // One 'Subscription / Subscribe' event per newsletter the server
135
+ // opted this user into, matching what browser/index.js emits for the
136
+ // 'identity-x-auto-signup' bus event in the email-link flow.
137
+ for (var pi = 0; pi < autoSignups.length; pi += 1) {
138
+ window.p1events('track', {
139
+ category: 'Subscription',
140
+ action: 'Subscribe',
141
+ props: {
142
+ idxEntity: data.entity,
143
+ actionSource: autoSignups[pi].actionSource || data.loginSource,
144
+ subscriptionEntity: autoSignups[pi].subscriptionEntity,
145
+ },
146
+ });
147
+ }
59
148
  }
60
- // Push to dataLayer for GTM/GA4 integration, matching the shape
61
- // produced by the standard email-link authenticate flow so existing
62
- // GTM triggers/variables pick it up. The actionSource value
63
- // ('google-one-tap') can be used to populate subscription_source
64
- // or other conversion metadata in GA4.
65
- if (window.dataLayer) {
66
- window.dataLayer.push({
149
+
150
+ // Push to dataLayer for GTM/GA4. Assign rather than guard: GTM can be
151
+ // deferred behind the script loader, and a One-Tap sign-in that lands
152
+ // before the container initializes would otherwise drop its events
153
+ // silently. Pushing into a plain array is safe — GTM replays whatever
154
+ // is already there when it loads.
155
+ window.dataLayer = window.dataLayer || [];
156
+
157
+ // Mirror the payload the standard email-link authenticate flow builds
158
+ // (browser/authenticate.vue + mixins/global-event-emitter.js) so GTM
159
+ // triggers and dataLayer variables resolve identically. That flow sets
160
+ // actionSource, loginSource AND source to the same value; a variable
161
+ // reading any one of the three has to work here too.
162
+ var idxAuthPayload = {
163
+ event: 'identity-x-authenticated',
164
+ 'identity-x': {
67
165
  event: 'identity-x-authenticated',
68
- 'identity-x': {
69
- event: 'identity-x-authenticated',
70
- id: data.user.id,
71
- email: data.user.email,
72
- entity: data.entity,
73
- actionSource: data.loginSource,
74
- },
166
+ ok: true,
167
+ applicationId: data.applicationId,
168
+ user: data.user,
169
+ id: data.user.id,
170
+ email: data.user.email,
171
+ verifiedCount: data.user.verifiedCount,
172
+ entity: data.entity,
173
+ requiresUserInput: data.requiresUserInput,
174
+ createdNewUser: eventData.createdNewUser,
175
+ additionalEventData: eventData,
176
+ actionSource: data.loginSource,
177
+ loginSource: data.loginSource,
178
+ source: data.loginSource,
179
+ },
180
+ };
181
+ window.dataLayer.push(idxAuthPayload);
182
+
183
+ // The 'subscribe' conversion keys off 'identity-x-auto-signup', not
184
+ // 'identity-x-authenticated' — one event per newsletter product the
185
+ // Omeda hook subscribed the user to. Shape matches
186
+ // mixins/global-auto-signup-event-emitter.js -> browser/index.js:
187
+ // { entity, ...autoSignup, event }.
188
+ for (var i = 0; i < autoSignups.length; i += 1) {
189
+ var signupData = { entity: data.entity };
190
+ var autoSignup = autoSignups[i];
191
+ for (var key in autoSignup) {
192
+ if (Object.prototype.hasOwnProperty.call(autoSignup, key)) {
193
+ signupData[key] = autoSignup[key];
194
+ }
195
+ }
196
+ signupData.event = 'identity-x-auto-signup';
197
+ window.dataLayer.push({
198
+ event: 'identity-x-auto-signup',
199
+ 'identity-x': signupData,
75
200
  });
76
201
  }
202
+
203
+ var href;
77
204
  if (data.requiresUserInput) {
78
205
  var destination = data.redirectTo || window.location.pathname;
79
- window.location.href = '/user/profile?returnTo=' + encodeURIComponent(destination);
206
+ href = '/user/profile?returnTo=' + encodeURIComponent(destination);
80
207
  } else {
81
- window.location.href = data.redirectTo || window.location.href;
208
+ href = data.redirectTo || window.location.href;
82
209
  }
210
+ // Give GTM a moment to dispatch the tags above before tearing down the
211
+ // document. The email-link flow navigates in the same tick, but it does
212
+ // so from /user/authenticate after a full page load, where the container
213
+ // is long since warm; a One-Tap sign-in can resolve much earlier in a
214
+ // page's life. A short delay is imperceptible next to the sign-in
215
+ // round-trip and cheaper than losing the conversion.
216
+ window.setTimeout(function() { window.location.href = href; }, 300);
83
217
  })
84
- .catch(function() {
85
- if (errorEl) {
86
- errorEl.textContent = 'An error occurred during Google sign-in. Please try again.';
87
- errorEl.style.display = 'block';
88
- }
218
+ .catch(function(e) {
219
+ fail('An error occurred during Google sign-in. Please try again.', e);
89
220
  });
90
221
  };
91
222
  })();
@@ -108,5 +239,5 @@ marko_template._ = marko_renderer(render, {
108
239
  });
109
240
 
110
241
  marko_template.meta = {
111
- id: "/@mindful-web/marko-web-identity-x$1.79.6/components/google-init.marko"
242
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/components/google-init.marko"
112
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.79.6/components/google-sign-in-button.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/google-sign-in-button.marko"
43
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/identify.marko",
53
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/non-auth-identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/non-auth-identify.marko",
48
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/subscribe.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.79.8/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.79.6/components/subscribe.marko",
76
+ id: "/@mindful-web/marko-web-identity-x$1.79.8/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
@@ -1,6 +1,7 @@
1
1
  const { get, getAsArray, getAsObject } = require('@mindful-web/object-path');
2
2
  const { isFunction: isFn } = require('@mindful-web/utils');
3
3
  const validHooks = require('./hooks');
4
+ const defaultAddRequestAttributes = require('./utils/add-custom-attributes');
4
5
 
5
6
  const { log } = console;
6
7
 
@@ -15,7 +16,17 @@ class IdentityXConfiguration {
15
16
  * @param {string[]} [options.requiredCreateFieldRows] Required fields, when creating a new user.
16
17
  * @param {string[]} [options.activeCustomFieldIds] Limit displayed custom fields, if present.
17
18
  * @param {string[]} [options.hiddenFields] The fields to hide from the profile.
18
- * @param {function} [options.onHookError]
19
+ * @param {function} [options.onHookError] Reporter for non-fatal errors. Called as
20
+ * `(error, customAttributes)` — matches `newrelic.noticeError`.
21
+ * @param {function} [options.addRequestAttributes] Sink for per-request telemetry,
22
+ * called with a flat object of primitives. **Defaults to tagging the current New
23
+ * Relic transaction** when the app has already loaded the agent (every marko-web
24
+ * site does, at boot) — so the attributes ride on both the `Transaction` and any
25
+ * `TransactionError` it produces, and a 500 arrives already saying which step
26
+ * failed and for whom. No site configuration required; inert without New Relic.
27
+ * Deliberately not `noticeError` — that would record a second error alongside the
28
+ * one the error handler already captures, and double the counts. Pass your own
29
+ * function to override, or a no-op to disable.
19
30
  * @param {object} [options.googleAuth] Google Sign-In config. When `clientId` is
20
31
  * set, the One-Tap prompt / "Sign in with Google" button render in the auth
21
32
  * templates and `POST /__idx/google` accepts credentials. Dormant otherwise.
@@ -40,6 +51,7 @@ class IdentityXConfiguration {
40
51
  gtmUserFields = {},
41
52
  googleAuth = {},
42
53
  onHookError,
54
+ addRequestAttributes,
43
55
  ...rest
44
56
  } = {}) {
45
57
  if (!appId) throw new Error('Unable to configure IdentityX: no Application ID was provided.');
@@ -58,13 +70,18 @@ class IdentityXConfiguration {
58
70
  booleanQuestionsLabel,
59
71
  gtmUserFields,
60
72
  googleAuth,
61
- onHookError: (e) => {
73
+ // Extra args are forwarded so callers can pass structured context —
74
+ // `newrelic.noticeError(error, customAttributes)` takes it natively.
75
+ onHookError: (e, ...args) => {
62
76
  if (process.env.NODE_ENV === 'development') {
63
- log('ERROR IN IDENTITY-X HOOK', e);
77
+ log('ERROR IN IDENTITY-X HOOK', e, ...args);
64
78
  if (e.networkError) log('Network Error', get(e, 'networkError.result.errors.0'));
65
79
  }
66
- if (isFn(onHookError)) onHookError(e);
80
+ if (isFn(onHookError)) onHookError(e, ...args);
67
81
  },
82
+ addRequestAttributes: isFn(addRequestAttributes)
83
+ ? addRequestAttributes
84
+ : defaultAddRequestAttributes,
68
85
  ...rest,
69
86
  };
70
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-identity-x",
3
- "version": "1.79.6",
3
+ "version": "1.79.8",
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>",
@@ -38,5 +38,5 @@
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "gitHead": "ffb05490d900bcdc2d3a2230c6e41da40f9f07ce"
41
+ "gitHead": "aafeaa99860062c3faa70b67bab0e69b49d13632"
42
42
  }
package/routes/google.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const gql = require('graphql-tag');
2
2
  const { OAuth2Client } = require('google-auth-library');
3
- const { asyncRoute } = require('@mindful-web/utils');
3
+ const { asArray, asyncRoute } = require('@mindful-web/utils');
4
4
  const IMPERSONATE_APP_USER = require('../api/mutations/impersonate-app-user');
5
5
  const tokenCookie = require('../utils/token-cookie');
6
6
  const contextCookie = require('../utils/context-cookie');
@@ -14,6 +14,16 @@ const UPDATE_OWN_APP_USER = gql`
14
14
 
15
15
  const isEmpty = (v) => v == null || v === '';
16
16
 
17
+ // Cache one OAuth2Client per client id. `verifyIdToken` fetches (and caches on
18
+ // the *instance*) Google's public signing certs, so constructing a client per
19
+ // request re-fetched them on every sign-in — turning a transient network blip
20
+ // or rate-limit at Google into a failed sign-in.
21
+ const oauthClients = new Map();
22
+ const getOAuthClient = (clientId) => {
23
+ if (!oauthClients.has(clientId)) oauthClients.set(clientId, new OAuth2Client(clientId));
24
+ return oauthClients.get(clientId);
25
+ };
26
+
17
27
  const LOGIN_SOURCE = 'google-one-tap';
18
28
 
19
29
  // Namespace under which the Google account id (the JWT `sub`) is stored as an
@@ -50,7 +60,21 @@ module.exports = asyncRoute(async (req, res) => {
50
60
  const { config } = identityX;
51
61
  const onError = config.get('onHookError') || defaultOnError;
52
62
 
63
+ // Tag the transaction as the route progresses. Whatever step is recorded when
64
+ // the request dies is the step that failed, so an error arrives already
65
+ // localized instead of being a bare GraphQL message to reverse-engineer.
66
+ const addAttrs = config.get('addRequestAttributes') || (() => {});
67
+ const setStep = (step) => addAttrs({ idxGoogleStep: step });
68
+
53
69
  const { clientId, missingFieldsBehavior = 'profile-gate' } = config.getAsObject('googleAuth');
70
+ // `surface` is the GIS `select_by` bucket ('button' | 'prompt'), sent by the
71
+ // client. Both surfaces share a route, a loginSource and an impersonation
72
+ // method, so without it there is no way to tell from the server whether the
73
+ // rendered button works independently of the One-Tap prompt.
74
+ addAttrs({
75
+ idxGoogleSurface: body.surface === 'button' ? 'button' : 'prompt',
76
+ idxGoogleMissingFieldsBehavior: missingFieldsBehavior,
77
+ });
54
78
 
55
79
  if (!clientId) {
56
80
  res.status(400).json({ ok: false, error: 'Google Sign-In is not configured for this site.' });
@@ -62,7 +86,8 @@ module.exports = asyncRoute(async (req, res) => {
62
86
  }
63
87
 
64
88
  // Verify the Google JWT and extract user info.
65
- const oauthClient = new OAuth2Client(clientId);
89
+ setStep('verify-credential');
90
+ const oauthClient = getOAuthClient(clientId);
66
91
  const ticket = await oauthClient.verifyIdToken({ idToken: credential, audience: clientId });
67
92
  const googlePayload = ticket.getPayload();
68
93
  const {
@@ -75,11 +100,14 @@ module.exports = asyncRoute(async (req, res) => {
75
100
  if (!email) throw new Error('Google credential did not include an email address.');
76
101
 
77
102
  // Find or create the identity-x user.
103
+ setStep('load-app-user');
78
104
  let appUser = await identityX.loadAppUserByEmail(email);
79
105
  const createdNewUser = !appUser;
80
106
  if (!appUser) {
107
+ setStep('create-app-user');
81
108
  appUser = await identityX.createAppUser({ email });
82
109
  }
110
+ addAttrs({ idxUserId: appUser.id, idxCreatedNewUser: createdNewUser });
83
111
 
84
112
  // Persist the Google account id (the JWT `sub`) as an idx external id so the
85
113
  // user is linked to its Google identity. Email stays the primary key; this is
@@ -92,6 +120,7 @@ module.exports = asyncRoute(async (req, res) => {
92
120
  && extId.identifier.value === googleSub
93
121
  ));
94
122
  if (!alreadyLinked) {
123
+ setStep('link-google-sub');
95
124
  try {
96
125
  // If this sub is already attached to a different user (e.g. the Google
97
126
  // account's email changed and a new user was created under it), report
@@ -104,7 +133,7 @@ module.exports = asyncRoute(async (req, res) => {
104
133
  if (existing && existing.id !== appUser.id) {
105
134
  onError(new Error(
106
135
  `Google sub already linked to idx user ${existing.id}, not ${appUser.id}`,
107
- ));
136
+ ), { idxGoogleStep: 'link-google-sub', idxUserId: appUser.id, idxConflictUserId: existing.id });
108
137
  } else {
109
138
  await identityX.addExternalUserId({
110
139
  userId: appUser.id,
@@ -114,11 +143,14 @@ module.exports = asyncRoute(async (req, res) => {
114
143
  }
115
144
  } catch (e) {
116
145
  // Supplementary linkage — never block authentication on a failure here.
117
- onError(e);
146
+ // Reported with context so these stay distinguishable from fatal errors
147
+ // on the same route (they arrive on a 200).
148
+ onError(e, { idxGoogleStep: 'link-google-sub', idxUserId: appUser.id });
118
149
  }
119
150
  }
120
151
  }
121
152
 
153
+ setStep('impersonate-app-user');
122
154
  // Impersonate the user with verify: true — marks them as verified and returns
123
155
  // a session token, bypassing the normal email-link flow.
124
156
  const { data: impersonateData } = await identityX.client.mutate({
@@ -140,9 +172,13 @@ module.exports = asyncRoute(async (req, res) => {
140
172
 
141
173
  // Re-init the Apollo client with the user's own session token so that
142
174
  // updateOwnAppUser is authenticated as this user (not as the org admin).
143
- identityX.setToken(authToken);
175
+ // `setToken` takes the token *string* — passing the `{ id, value }` object
176
+ // produced an `AppUser [object Object]` authorization header, which failed
177
+ // every subsequent user-authenticated call (see updateOwnAppUser below).
178
+ identityX.setToken(authToken.value);
144
179
 
145
180
  // Set name fields from Google as verified data if not already present.
181
+ setStep('update-own-app-user');
146
182
  const needsNameUpdate = !appUser.givenName || !appUser.familyName;
147
183
  if (needsNameUpdate && (givenName || familyName)) {
148
184
  const nameInput = {};
@@ -155,6 +191,7 @@ module.exports = asyncRoute(async (req, res) => {
155
191
  }
156
192
 
157
193
  // Load the refreshed user so hooks and field checks have up-to-date data.
194
+ setStep('load-refreshed-user');
158
195
  const user = await identityX.findUserById(appUser.id);
159
196
  const entity = await identityX.generateEntityId({ userId: appUser.id });
160
197
 
@@ -169,31 +206,96 @@ module.exports = asyncRoute(async (req, res) => {
169
206
  ];
170
207
  requiresUserInput = requiredFields.some((key) => isEmpty(user[key]));
171
208
  }
209
+ addAttrs({ idxRequiresUserInput: requiresUserInput });
210
+
211
+ // Tenant Omeda formatters write `autoSignups` into their `additionalEventData`
212
+ // in place — one entry per newsletter product they opt the user into — and
213
+ // that object is what goes out in the response body. It is the only channel by
214
+ // which the browser learns a subscription was created; without it no
215
+ // `identity-x-auto-signup` event reaches GTM/GA4 (the "subscribe" conversion)
216
+ // or p1events, and One Tap looks like it subscribes nobody even though the
217
+ // Omeda opt-in ran fine.
218
+ //
219
+ // Give each hook its OWN object and merge after, rather than sharing one
220
+ // reference, because tenants disagree about where the write lives AND several
221
+ // write in both places:
222
+ // onLoginLinkSentFormatter only ....... ab-media, allured, encore360, pmmi
223
+ // onAuthenticationSuccessFormatter only fusable, cox-matthews
224
+ // BOTH ................................ industrial, ironmarkets, watt
225
+ // Every tenant *assigns* `autoSignups` rather than appending, so a shared
226
+ // reference would let the second hook clobber the first — silently dropping a
227
+ // whole set of subscribe events for the three both-hook repos. In the
228
+ // email-link flow the two hooks fire on separate requests (login vs
229
+ // authenticate) and each reports its own set, so the union is what preserves
230
+ // existing behaviour here.
231
+ const linkSentEventData = { createdNewUser };
172
232
 
173
233
  // Fire onLoginLinkSent before onAuthenticationSuccess so that hooks wired to
174
234
  // it (e.g. Omeda rapid-identify and subscription sync) run for Google users
175
235
  // the same way they do for email-link users. This also populates
176
236
  // user.externalIds with the Omeda encryptedCustomerId so the subsequent
177
237
  // onAuthenticationSuccess hook can set oly_enc_id in the response.
238
+ setStep('hooks-login-link-sent');
178
239
  await callHooksFor(identityX, 'onLoginLinkSent', {
179
240
  req,
180
241
  source: LOGIN_SOURCE,
181
242
  user,
243
+ additionalEventData: linkSentEventData,
182
244
  });
183
245
 
184
246
  // Re-fetch so onAuthenticationSuccess sees the externalIds written by
185
247
  // the onLoginLinkSent hook (Omeda rapid-identify).
186
248
  const userAfterLinkSent = await identityX.findUserById(appUser.id);
187
249
 
250
+ // `requiresUserInput` is exposed to this hook only. The onLoginLinkSent
251
+ // formatters read a truthy value as "skip the Omeda subscription lookup" and
252
+ // then opt the user into every configured deployment type without deduping
253
+ // against what they already hold — a phantom auto-signup, and an inflated GA4
254
+ // subscribe count, for an existing Omeda customer who merely lacks an idx
255
+ // required field. The email-link flow never exposes it there either:
256
+ // `routes/login.js` 400s with `requiresUserInput` before any hook runs.
257
+ const authEventData = { createdNewUser, requiresUserInput };
258
+
259
+ setStep('hooks-authentication-success');
188
260
  await callHooksFor(identityX, 'onAuthenticationSuccess', {
189
261
  req,
190
262
  res,
191
263
  user: userAfterLinkSent,
192
264
  authToken,
193
265
  loginSource: LOGIN_SOURCE,
194
- additionalEventData: { createdNewUser, requiresUserInput },
266
+ additionalEventData: authEventData,
195
267
  });
196
268
 
269
+ // Union of both hooks' auto-signups, deduped by product so a tenant that
270
+ // opts into the same newsletter from both hooks yields one subscribe event,
271
+ // not two.
272
+ const seenProducts = new Set();
273
+ const autoSignups = [
274
+ ...asArray(linkSentEventData.autoSignups),
275
+ ...asArray(authEventData.autoSignups),
276
+ ]
277
+ .filter((signup) => {
278
+ const key = signup.productId != null
279
+ ? `product:${signup.productId}`
280
+ : `entity:${signup.subscriptionEntity}`;
281
+ if (seenProducts.has(key)) return false;
282
+ seenProducts.add(key);
283
+ return true;
284
+ });
285
+
286
+ const additionalEventData = {
287
+ ...linkSentEventData,
288
+ ...authEventData,
289
+ ...(autoSignups.length && { autoSignups }),
290
+ };
291
+
292
+ // A new user with zero auto-signups is the shape this whole bug had: the
293
+ // sign-in succeeds, Omeda is updated, and GA4 records no conversion. Surface
294
+ // the count so a regression is a visible number on the transaction rather
295
+ // than something a stakeholder notices in a report weeks later.
296
+ addAttrs({ idxAutoSignupCount: autoSignups.length });
297
+
298
+ setStep('complete');
197
299
  res.json({
198
300
  ok: true,
199
301
  requiresUserInput,
@@ -201,6 +303,7 @@ module.exports = asyncRoute(async (req, res) => {
201
303
  applicationId: config.getAppId(),
202
304
  user: userAfterLinkSent,
203
305
  loginSource: LOGIN_SOURCE,
306
+ additionalEventData,
204
307
  entity,
205
308
  });
206
309
  });
package/service.js CHANGED
@@ -355,8 +355,9 @@ class IdentityX {
355
355
  }
356
356
 
357
357
  /**
358
+ * Sets the active app-user token and re-inits the Apollo client with it.
358
359
  *
359
- * @param {*} param0
360
+ * @param {String} token The token *value* (not the `{ id, value }` object).
360
361
  */
361
362
  setToken(token) {
362
363
  this.token = token;
@@ -379,7 +380,11 @@ class IdentityX {
379
380
  });
380
381
  const { token } = data.impersonateAppUser;
381
382
  tokenCookie.setTo(this.res, token.value);
382
- this.setToken(token);
383
+ // `setToken` expects the token string (as the constructor supplies from the
384
+ // cookie) — passing the `{ id, value }` object here produced an
385
+ // `AppUser [object Object]` authorization header on the re-inited client,
386
+ // and left `this.token` as an object for `logoutAppUser`.
387
+ this.setToken(token.value);
383
388
  }
384
389
 
385
390
  async logoutAppUser() {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Default sink for `config.addRequestAttributes` — tags the current New Relic
3
+ * transaction, with no configuration and no New Relic dependency.
4
+ *
5
+ * The agent is used **only when the application has already loaded it**. Every
6
+ * marko-web site requires its `newrelic.js` at boot, so by the time a request
7
+ * is served the module is in `require.cache` and `require('newrelic')` returns
8
+ * that same initialized singleton. Requiring it unconditionally would instead
9
+ * *start* an agent on sites that don't use New Relic, which is why this checks
10
+ * the cache rather than just try/catching the require.
11
+ *
12
+ * Net effect: attributes appear fleet-wide on a dependency bump alone, and the
13
+ * whole thing is inert on any site without New Relic. A site can still pass its
14
+ * own `addRequestAttributes` to override.
15
+ */
16
+
17
+ let resolvedPath;
18
+ let lookupFailed = false;
19
+ let agent = null;
20
+
21
+ const getAgent = () => {
22
+ if (agent) return agent;
23
+ if (lookupFailed) return null;
24
+ if (!resolvedPath) {
25
+ try {
26
+ resolvedPath = require.resolve('newrelic');
27
+ } catch (e) {
28
+ // Not installed — nothing to report to.
29
+ lookupFailed = true;
30
+ return null;
31
+ }
32
+ }
33
+ // `newrelic` is intentionally NOT a dependency of this package: declaring it
34
+ // would install it on every monorail site, and requiring an uninitialized
35
+ // agent starts one. We only ever pick up an instance the host app already
36
+ // created, which is why the cache check above guards this require.
37
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
38
+ if (require.cache[resolvedPath]) agent = require('newrelic');
39
+ return agent;
40
+ };
41
+
42
+ module.exports = (attributes) => {
43
+ const nr = getAgent();
44
+ if (!nr || typeof nr.addCustomAttributes !== 'function') return;
45
+ try {
46
+ nr.addCustomAttributes(attributes);
47
+ } catch (e) {
48
+ // Telemetry must never break a request.
49
+ }
50
+ };