@mindful-web/marko-web-identity-x 1.79.9 → 1.80.1

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
@@ -74,6 +74,34 @@ Server-side IdentityX GraphQL client (Apollo, idx-compat). Key methods: `loadAct
74
74
  `generateEntityId`, `sendLoginLink`, `checkContentAccess`, cookie helpers (`setToken`,
75
75
  `setIdentityCookie`, `getIdentity`). Org-scoped writes use `getOrgUserApiToken()`.
76
76
 
77
+ **Request timeout (`utils/create-client.js`).** Every idx call is bounded at **15s** per request
78
+ (`IDENTITYX_REQUEST_TIMEOUT_MS` to override; `linkConfig.fetchOptions.timeout` per client). This is
79
+ not a tuning knob, it is a correctness fix — **do not remove it.** `node-fetch` v2 has no default
80
+ timeout, so a stalled idx call previously hung the Express request forever: nothing threw, no
81
+ response was written, no 500 was returned, and because the New Relic Node agent records
82
+ `res.statusCode` — which defaults to **200** when nothing is ever written — the failure was logged as
83
+ a *successful* request with no error event. It was undetectable by design.
84
+
85
+ Found 2026-07 on `POST /__idx/google` (overdriveonline.com): 15 of 15 sign-ins that had just created
86
+ the app user stalled inside the impersonation mutation at avg **53.1s**, max **60.018s** — the flat
87
+ 60s ceiling of an upstream idle timeout cutting a connection this process was still waiting on. Users
88
+ saw a One-Tap prompt that did nothing and had to trigger it a second time; 13 of 16 never came back.
89
+ Successful requests on the same route averaged 12.4s *total* across ~10 sequential idx calls, so a
90
+ 15s per-call budget has ample headroom. Every operation is also wrapped by
91
+ `@mindful-web/utils` `instrumentApolloClient`, emitting a **`MindfulApiCall`** event
92
+ (`api`, `operation`, `kind`, `ms`, `outcome` of `ok|http-error|timeout|error`) plus a named trace
93
+ segment — a trace is the only place a call still *in flight* when its transaction dies leaves any
94
+ record. Same treatment on the Omeda client in `marko-web-omeda/middleware/graphql-client.js`
95
+ (20s, `OMEDA_REQUEST_TIMEOUT_MS`), which had the identical no-timeout defect and sits on the blocking
96
+ path of authentication. To compare upstreams:
97
+ `SELECT count(*), average(ms), percentile(ms, 95), max(ms) FROM MindfulApiCall FACET api, operation, outcome`.
98
+
99
+ It wraps `client.query`/`client.mutate`, **not** `fetch` — `@parameter1/omeda-graphql-client` sets
100
+ `fetch` *after* spreading the caller's `linkConfig`, so a wrapped transport cannot be injected from
101
+ outside that package (`fetchOptions` does survive, which is why the timeout can be). An
102
+ `ApolloLink(...).map()` was the other option and is worse: it only sees successful responses, so the
103
+ slow-and-failing calls would be exactly the ones missing.
104
+
77
105
  ## Hooks (`hooks.js`)
78
106
 
79
107
  `onLoadActiveContext`, `onLoginLinkSent`, `onAuthenticationSuccess`, `onLogout`,
@@ -104,7 +132,47 @@ conflict-guarded; non-fatal via `config.get('onHookError')`) → `impersonateApp
104
132
  → backfill given/family name only if absent → fire `onLoginLinkSent` **then** (re-fetch)
105
133
  `onAuthenticationSuccess`, same order/hooks as email-link so Omeda rapid-identify + subscription
106
134
  sync run identically. `profile-gate` computes `requiresUserInput` by checking required fields are
107
- non-empty (deliberately not `forceProfileReVerification`). Responds `{ ok, requiresUserInput,
135
+ non-empty (deliberately not `forceProfileReVerification`). The impersonation mutation is **retried
136
+ once** on transport failures only (`idxImpersonateAttempts` records how many it took): stalls
137
+ correlated exactly with impersonating a user created milliseconds earlier — a read-after-write race on
138
+ the idx API — and every user who retried by hand succeeded on the second go. GraphQL validation and
139
+ authorization errors are deliberately *not* retried; they fail identically twice and only double the
140
+ user's wait.
141
+
142
+ **`onLoginLinkSent` is force-awaited here** (`callHooksFor(..., { forceAwait: true })`), because
143
+ everything after it depends on its side effects and `shouldAwait.onLoginLinkSent` defaults to
144
+ **false**. That default is right for email-link and wrong for One Tap, and the difference is
145
+ structural: email-link fires `onLoginLinkSent` on the `/login` request when the link is *sent* and
146
+ `onAuthenticationSuccess` on a separate request minutes later, so Omeda rapid-identify has long
147
+ finished. One Tap collapses both into one request milliseconds apart — un-awaited, rapid-identify has
148
+ usually not yet written the `encryptedCustomerId` external id when `appendSubscriptions` looks for it,
149
+ so it bails, writes no `autoSignups`, and **no `subscribe` conversion is ever reported**. That is the
150
+ `idxAutoSignupCount: 0` seen on every completed One Tap sign-in on overdriveonline.com. Safe to await
151
+ only because the Omeda client now has a request timeout.
152
+
153
+ **The name backfill is non-fatal, and must stay that way.** `GoogleSignInUpdateUser`
154
+ (`updateOwnAppUser`) is the **only** call in this route authenticated as the *user* rather than the
155
+ org — every other one passes `context: { apiToken: getOrgUserApiToken() }` — so it is the only one
156
+ depending on the session token minted moments earlier already being readable. Production shows it
157
+ returning *"The provided value is not a valid token"*, while the same token value works on the very
158
+ next request arriving from the cookie: a read-after-write race on the idx token store. Retried once
159
+ for that reason.
160
+
161
+ Non-fatal matters more than the retry. It backfills a first and last name — cosmetic — but
162
+ `tokenCookie.setTo` has **already run**, so the user is authenticated by the time it executes.
163
+ Throwing turned a fully successful sign-in into a 500, and the client, seeing `!data.ok`, ran `fail()`
164
+ and redirected a *logged-in* user to `/user/login`. A missing display name must never cost someone
165
+ their session. Failures report through `onHookError` and the route continues.
166
+
167
+ `FACET idxNameBackfill` (`ok` | `retried` | `failed`) distinguishes the two bugs that share this one
168
+ error message: if `retried` dominates it is the race; if `failed` dominates the impersonation token
169
+ simply cannot authorize this mutation, and the fix is to stop using it — backfill before impersonation
170
+ via `setUnverifiedAppUserData` (org-token, but writes *unverified* data, so it is a semantic change
171
+ worth deciding deliberately).
172
+
173
+ `idxGoogleStepMs` records elapsed ms at the start of each step, making the route self-profiling:
174
+ `FACET idxGoogleStep SELECT max(idxGoogleStepMs)` shows where the ~12s on a *successful* sign-in
175
+ goes, and a request that dies mid-flight still reports how far it got. Responds `{ ok, requiresUserInput,
108
176
  redirectTo, user, entity, additionalEventData, … }`; the client redirects to `/user/profile` when
109
177
  input is required.
110
178
 
@@ -170,7 +238,8 @@ meter / gate.
170
238
  **Telemetry.** Two seams. Neither needs site configuration:
171
239
  - `config.addRequestAttributes` — called with a flat object as the route progresses
172
240
  (`idxGoogleStep`, `idxUserId`, `idxCreatedNewUser`, `idxRequiresUserInput`, `idxGoogleSurface`,
173
- `idxGoogleMissingFieldsBehavior`, `idxAutoSignupCount`). **Defaults to `utils/add-custom-attributes.js`**, which tags
241
+ `idxGoogleMissingFieldsBehavior`, `idxAutoSignupCount`, `idxImpersonateAttempts`,
242
+ `idxNameBackfill`, `idxNameBackfillAttempts`). **Defaults to `utils/add-custom-attributes.js`**, which tags
174
243
  the current New Relic transaction, so the attributes ride on the `Transaction` *and* any
175
244
  `TransactionError` it produces — a 500 says which step failed instead of being a bare GraphQL
176
245
  message. That default resolves the agent **only if the app already loaded it** (checks
@@ -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.9/components/access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/access.marko",
56
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/comment-stream.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/comment-stream.marko",
94
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/context.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/context.marko",
52
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-access.marko",
182
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-authenticate.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-authenticate.marko",
84
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-change-email.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-change-email.marko",
62
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-login.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-login.marko",
95
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-logout.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-logout.marko",
32
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-profile.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-profile.marko",
87
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-register.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/form-register.marko",
28
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.79.9/components/google-init.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.79.9/components/google-init.marko"
242
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.79.9/components/google-sign-in-button.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/google-sign-in-button.marko"
43
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/identify.marko",
53
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/non-auth-identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/non-auth-identify.marko",
48
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/subscribe.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.80.1/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.9/components/subscribe.marko",
76
+ id: "/@mindful-web/marko-web-identity-x$1.80.1/components/subscribe.marko",
77
77
  component: "./subscribe.marko",
78
78
  tags: [
79
79
  "@mindful-web/marko-web/components/browser-component.marko",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-identity-x",
3
- "version": "1.79.9",
3
+ "version": "1.80.1",
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>",
@@ -13,9 +13,9 @@
13
13
  "test": "yarn compile --no-clean && yarn lint"
14
14
  },
15
15
  "dependencies": {
16
- "@mindful-web/marko-web-gtm": "^1.57.0",
17
- "@mindful-web/object-path": "^1.57.0",
18
- "@mindful-web/utils": "^1.57.0",
16
+ "@mindful-web/marko-web-gtm": "^1.80.0",
17
+ "@mindful-web/object-path": "^1.80.0",
18
+ "@mindful-web/utils": "^1.80.0",
19
19
  "apollo-cache-inmemory": "^1.6.6",
20
20
  "apollo-client": "^2.6.10",
21
21
  "apollo-link-context": "^1.0.20",
@@ -38,5 +38,5 @@
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "gitHead": "450cfb4efb7f30564f3230912fce04b5fc574a1a"
41
+ "gitHead": "956e3462d636f6e7785c1e82b99d66b4c410e9df"
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 { asArray, asyncRoute } = require('@mindful-web/utils');
3
+ const { asArray, asyncRoute, sleep } = 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');
@@ -26,6 +26,47 @@ const getOAuthClient = (clientId) => {
26
26
 
27
27
  const LOGIN_SOURCE = 'google-one-tap';
28
28
 
29
+ // One retry, not more: the failure mode this addresses is a read-after-write
30
+ // race that clears on the next attempt, and each attempt costs a full request
31
+ // timeout. Three attempts would put a failing sign-in past the upstream 60s
32
+ // budget we are trying to stay inside.
33
+ const IMPERSONATE_MAX_ATTEMPTS = 2;
34
+ const IMPERSONATE_RETRY_DELAY_MS = 250;
35
+
36
+ // Same shape as the impersonation retry, and for the same suspected cause: a
37
+ // read-after-write race, this time on the token the previous step just minted.
38
+ const NAME_BACKFILL_MAX_ATTEMPTS = 2;
39
+ const NAME_BACKFILL_RETRY_DELAY_MS = 250;
40
+
41
+ /**
42
+ * A session token that the idx API does not (yet) recognise.
43
+ *
44
+ * Apollo exposes GraphQL errors both individually on `graphQLErrors` and folded
45
+ * into `error.message` as "GraphQL error: <message>"; check both so this does not
46
+ * depend on which shape the caller happens to see.
47
+ */
48
+ const isFreshTokenError = (e) => {
49
+ if (!e) return false;
50
+ const messages = [e.message, ...asArray(e.graphQLErrors).map((g) => g && g.message)];
51
+ return messages.some((m) => /not a valid token|invalid token|token was not found/i.test(String(m)));
52
+ };
53
+
54
+ /**
55
+ * Transport-level failures only — a timeout, reset or DNS blip is worth
56
+ * repeating; a GraphQL validation or authorization error is not, and repeating
57
+ * it only doubles the user's wait before the identical 500.
58
+ *
59
+ * Apollo wraps transport failures as `networkError`; `node-fetch` raises its own
60
+ * timeout as a `FetchError` reading "network timeout at: <uri>".
61
+ */
62
+ const isRetryableIdxError = (e) => {
63
+ if (!e) return false;
64
+ if (e.networkError) return true;
65
+ if (e.name === 'FetchError' || e.type === 'request-timeout') return true;
66
+ return /network timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up/i
67
+ .test(String(e.message));
68
+ };
69
+
29
70
  // Namespace under which the Google account id (the JWT `sub`) is stored as an
30
71
  // idx external id, mirroring the Omeda { provider, tenant, type } convention.
31
72
  // These values must remain stable — changing them orphans existing links.
@@ -63,8 +104,19 @@ module.exports = asyncRoute(async (req, res) => {
63
104
  // Tag the transaction as the route progresses. Whatever step is recorded when
64
105
  // the request dies is the step that failed, so an error arrives already
65
106
  // localized instead of being a bare GraphQL message to reverse-engineer.
107
+ //
108
+ // `idxGoogleStepMs` is the elapsed time when each step *began*, which makes the
109
+ // route self-profiling: `FACET idxGoogleStep` with `max(idxGoogleStepMs)` shows
110
+ // where the time goes, and a request that dies mid-flight still reports how far
111
+ // it got and when. Sign-ins here average ~12s even when they succeed, and
112
+ // without this there is no way to tell which of the ~10 sequential upstream
113
+ // calls owns that.
114
+ const startedAt = Date.now();
66
115
  const addAttrs = config.get('addRequestAttributes') || (() => {});
67
- const setStep = (step) => addAttrs({ idxGoogleStep: step });
116
+ const setStep = (step) => addAttrs({
117
+ idxGoogleStep: step,
118
+ idxGoogleStepMs: Date.now() - startedAt,
119
+ });
68
120
 
69
121
  const { clientId, missingFieldsBehavior = 'profile-gate' } = config.getAsObject('googleAuth');
70
122
  // `surface` is the GIS `select_by` bucket ('button' | 'prompt'), sent by the
@@ -153,18 +205,60 @@ module.exports = asyncRoute(async (req, res) => {
153
205
  setStep('impersonate-app-user');
154
206
  // Impersonate the user with verify: true — marks them as verified and returns
155
207
  // a session token, bypassing the normal email-link flow.
156
- const { data: impersonateData } = await identityX.client.mutate({
157
- mutation: IMPERSONATE_APP_USER,
158
- variables: {
159
- input: {
160
- id: appUser.id,
161
- method: 'GOOGLE_ONE_TAP',
162
- verify: true,
163
- },
164
- },
165
- context: { apiToken: identityX.getOrgUserApiToken() },
166
- });
167
- const { token: authToken } = impersonateData.impersonateAppUser;
208
+ //
209
+ // Retried once, because this is precisely where new-user sign-ins were
210
+ // observed to die. On overdriveonline.com in 2026-07, 15 of 15 requests that
211
+ // had just created the app user stalled inside this mutation (avg 53.1s, max
212
+ // 60.018s — an upstream 60s idle timeout) and never wrote a response, so the
213
+ // browser showed nothing at all and the user had to trigger One Tap a second
214
+ // time. Every hand-retry then succeeded, with `createdNewUser: false`. That
215
+ // correlation — stalls only when impersonating a user created milliseconds
216
+ // earlier — points at a read-after-write race on the idx API, which is
217
+ // exactly the shape a single retry fixes.
218
+ //
219
+ // `create-client.js` now bounds each call, so the stall arrives as a
220
+ // FetchError in ~15s rather than hanging; worst case here is two attempts and
221
+ // then a 500 the client can actually surface, instead of a 60s silent no-op.
222
+ let impersonateData;
223
+ let impersonateAttempts = 0;
224
+ for (;;) {
225
+ impersonateAttempts += 1;
226
+ try {
227
+ // eslint-disable-next-line no-await-in-loop
228
+ ({ data: impersonateData } = await identityX.client.mutate({
229
+ mutation: IMPERSONATE_APP_USER,
230
+ variables: {
231
+ input: {
232
+ id: appUser.id,
233
+ method: 'GOOGLE_ONE_TAP',
234
+ verify: true,
235
+ },
236
+ },
237
+ context: { apiToken: identityX.getOrgUserApiToken() },
238
+ }));
239
+ break;
240
+ } catch (e) {
241
+ // Only transport failures are worth repeating. A GraphQL validation or
242
+ // authorization error fails identically every time, so retrying it just
243
+ // doubles the user's wait before the same 500.
244
+ if (impersonateAttempts >= IMPERSONATE_MAX_ATTEMPTS || !isRetryableIdxError(e)) {
245
+ addAttrs({ idxImpersonateAttempts: impersonateAttempts });
246
+ throw e;
247
+ }
248
+ setStep('impersonate-app-user-retry');
249
+ // eslint-disable-next-line no-await-in-loop
250
+ await sleep(IMPERSONATE_RETRY_DELAY_MS);
251
+ }
252
+ }
253
+ addAttrs({ idxImpersonateAttempts: impersonateAttempts });
254
+
255
+ // Guard the destructure: a null payload here previously became an opaque
256
+ // "cannot destructure" TypeError several frames from the real cause.
257
+ const impersonated = impersonateData && impersonateData.impersonateAppUser;
258
+ if (!impersonated || !impersonated.token) {
259
+ throw new Error('Google sign-in could not establish a session: impersonation returned no token.');
260
+ }
261
+ const { token: authToken } = impersonated;
168
262
 
169
263
  tokenCookie.setTo(res, authToken.value);
170
264
  contextCookie.setTo(res, { loginSource: LOGIN_SOURCE });
@@ -190,10 +284,55 @@ module.exports = asyncRoute(async (req, res) => {
190
284
  if (familyName && !appUser.familyName) nameInput.familyName = familyName;
191
285
  if (Object.keys(nameInput).length) {
192
286
  setStep('update-own-app-user');
193
- await identityX.client.mutate({
194
- mutation: UPDATE_OWN_APP_USER,
195
- variables: { input: nameInput },
196
- });
287
+ // This is the ONLY call in the route authenticated as the *user* rather than
288
+ // the org — every other one passes `context: { apiToken: getOrgUserApiToken() }`
289
+ // — so it is the only one that depends on the session token minted moments
290
+ // earlier already being readable. That is exactly why it is the only
291
+ // operation reported failing: production shows `GoogleSignInUpdateUser`
292
+ // returning "The provided value is not a valid token".
293
+ //
294
+ // The same token value works on the *next* request, arriving from the cookie
295
+ // set above — which is what makes a read-after-write race on the idx token
296
+ // store the leading explanation, so retry once.
297
+ //
298
+ // Non-fatal regardless of the cause, which matters more than the retry. This
299
+ // backfills a first and last name; it is cosmetic. But `tokenCookie.setTo`
300
+ // has already run, so the user IS authenticated by the time we get here —
301
+ // throwing turned a fully successful sign-in into a 500, and the client, on
302
+ // seeing `!data.ok`, ran `fail()` and redirected a logged-in user to
303
+ // /user/login. A missing display name must never cost someone their session.
304
+ let backfill = 'ok';
305
+ let attempts = 0;
306
+ for (;;) {
307
+ attempts += 1;
308
+ try {
309
+ // eslint-disable-next-line no-await-in-loop
310
+ await identityX.client.mutate({
311
+ mutation: UPDATE_OWN_APP_USER,
312
+ variables: { input: nameInput },
313
+ });
314
+ break;
315
+ } catch (e) {
316
+ const retryable = attempts < NAME_BACKFILL_MAX_ATTEMPTS
317
+ && (isFreshTokenError(e) || isRetryableIdxError(e));
318
+ if (!retryable) {
319
+ backfill = 'failed';
320
+ onError(e, {
321
+ idxGoogleStep: 'update-own-app-user',
322
+ idxUserId: appUser.id,
323
+ idxNameBackfillAttempts: attempts,
324
+ });
325
+ break;
326
+ }
327
+ backfill = 'retried';
328
+ // eslint-disable-next-line no-await-in-loop
329
+ await sleep(NAME_BACKFILL_RETRY_DELAY_MS);
330
+ }
331
+ }
332
+ // Measurable: `FACET idxNameBackfill` separates "the retry rescues it" (a
333
+ // race) from "it always fails" (an impersonation token that simply cannot
334
+ // authorize this mutation) — two different bugs with one error message.
335
+ addAttrs({ idxNameBackfill: backfill, idxNameBackfillAttempts: attempts });
197
336
  }
198
337
 
199
338
  // Load the refreshed user so hooks and field checks have up-to-date data.
@@ -241,13 +380,33 @@ module.exports = asyncRoute(async (req, res) => {
241
380
  // the same way they do for email-link users. This also populates
242
381
  // user.externalIds with the Omeda encryptedCustomerId so the subsequent
243
382
  // onAuthenticationSuccess hook can set oly_enc_id in the response.
383
+ //
384
+ // `forceAwait` because everything below *depends* on this hook's side effects,
385
+ // and `shouldAwait.onLoginLinkSent` defaults to FALSE — so by default these
386
+ // hooks were fire-and-forget and the dependency was a race this route lost
387
+ // most of the time.
388
+ //
389
+ // That default is correct for the email-link flow and wrong here, because the
390
+ // two flows have different shapes. Email-link fires onLoginLinkSent on the
391
+ // `/login` request when the link is *sent*, and onAuthenticationSuccess on a
392
+ // separate request minutes later when the user clicks it — rapid-identify has
393
+ // long since finished, so not awaiting it costs nothing. One Tap collapses both
394
+ // into a single request milliseconds apart. Un-awaited, Omeda rapid-identify
395
+ // has usually not yet written the `encryptedCustomerId` external id when
396
+ // `appendSubscriptions` looks for it, so it bails, produces no `autoSignups`,
397
+ // and no `subscribe` conversion is ever reported — the exact symptom seen on
398
+ // overdriveonline.com, where every completed One Tap sign-in recorded
399
+ // `idxAutoSignupCount: 0`.
400
+ //
401
+ // Bounded by the Omeda client's own request timeout, so awaiting cannot hang
402
+ // the route the way it could before that existed.
244
403
  setStep('hooks-login-link-sent');
245
404
  await callHooksFor(identityX, 'onLoginLinkSent', {
246
405
  req,
247
406
  source: LOGIN_SOURCE,
248
407
  user,
249
408
  additionalEventData: linkSentEventData,
250
- });
409
+ }, { forceAwait: true });
251
410
 
252
411
  // Re-fetch so onAuthenticationSuccess sees the externalIds written by
253
412
  // the onLoginLinkSent hook (Omeda rapid-identify).
@@ -7,22 +7,36 @@ const { isFunction: isFn } = require('@mindful-web/utils');
7
7
  * @param {IdentityX} service The IdentityX service instance.
8
8
  * @param {string} name The hook queue name
9
9
  * @param {object} params Parameters to send to the hook function
10
+ * @param {object} [options]
11
+ * @param {boolean} [options.forceAwait=false] Await every hook, ignoring each
12
+ * hook's own `shouldAwait`. For callers that depend on a hook's side effects
13
+ * *within the same request* — where fire-and-forget is a race, not an
14
+ * optimisation. See `routes/google.js`.
10
15
  */
11
- module.exports = async (service, name, params = {}) => {
16
+ module.exports = async (service, name, params = {}, { forceAwait = false } = {}) => {
12
17
  const { error } = console;
13
18
  const onError = service.config.get('onHookError');
14
19
  const onHookError = isFn(onError) ? onError : (e) => error(e);
15
20
  const promises = { wait: [], skip: [] };
16
21
  getAsArray(service, `config.hooks.${name}`).forEach(({ fn, shouldAwait }) => {
17
- const key = shouldAwait ? 'wait' : 'skip';
22
+ const key = shouldAwait || forceAwait ? 'wait' : 'skip';
18
23
  const additionalEventData = params.additionalEventData || {};
19
24
  const maybePromise = fn({
20
25
  ...params,
21
26
  additionalEventData,
22
27
  service,
23
28
  });
24
- if (maybePromise.catch) maybePromise.catch(onHookError);
25
- promises[key].push(maybePromise);
29
+ // Push the *handled* promise, not the raw one. Previously a failing awaited
30
+ // hook was reported twice — once by this per-promise catch and again by the
31
+ // `Promise.all` catch below — which doubled the count everywhere
32
+ // `onHookError` is wired to `newrelic.noticeError`. Attaching the handler
33
+ // here and awaiting the result keeps every rejection reported exactly once
34
+ // while still ensuring none goes unhandled (`Promise.all` only surfaces the
35
+ // first rejection, so the raw promises cannot be awaited directly).
36
+ const handled = maybePromise && isFn(maybePromise.catch)
37
+ ? maybePromise.catch(onHookError)
38
+ : maybePromise;
39
+ promises[key].push(handled);
26
40
  });
27
- if (promises.wait.length) await Promise.all(promises.wait).catch(onHookError);
41
+ if (promises.wait.length) await Promise.all(promises.wait);
28
42
  };
@@ -1,4 +1,5 @@
1
1
  const fetch = require('node-fetch');
2
+ const { instrumentApolloClient } = require('@mindful-web/utils');
2
3
  const { ApolloClient } = require('apollo-client');
3
4
  const { InMemoryCache, IntrospectionFragmentMatcher } = require('apollo-cache-inmemory');
4
5
  const { createHttpLink } = require('apollo-link-http');
@@ -10,6 +11,33 @@ const rootConfig = {
10
11
  ssrMode: true,
11
12
  };
12
13
 
14
+ /**
15
+ * Per-request timeout for idx API calls.
16
+ *
17
+ * `node-fetch` v2 has NO default timeout, so before this a stalled call to the
18
+ * idx API hung the Express request indefinitely: nothing threw, no response was
19
+ * written, no 500 was returned, and — because the Node agent records
20
+ * `res.statusCode`, which defaults to 200 when nothing is ever written — New
21
+ * Relic logged a *successful* 200 with no error event. The user saw a One-Tap
22
+ * prompt that silently did nothing.
23
+ *
24
+ * Measured on `POST /__idx/google` (overdriveonline.com, 2026-07): 15 of 15
25
+ * new-user sign-ins stalled inside the impersonation mutation at avg 53.1s /
26
+ * max 60.018s — the flat 60s ceiling of an upstream idle timeout cutting the
27
+ * connection while this process was still waiting. Successful requests on the
28
+ * same route averaged 12.4s total across ~10 sequential idx calls, so a 15s
29
+ * *per-call* budget leaves ample headroom while turning an indefinite hang into
30
+ * a fast, attributable `FetchError` -> 500 -> a real New Relic error event.
31
+ *
32
+ * Tunable without a deploy via `IDENTITYX_REQUEST_TIMEOUT_MS`; a caller can
33
+ * still override per client through `linkConfig.fetchOptions.timeout`.
34
+ */
35
+ const DEFAULT_TIMEOUT_MS = 15000;
36
+ const getTimeoutMs = () => {
37
+ const configured = Number.parseInt(process.env.IDENTITYX_REQUEST_TIMEOUT_MS, 10);
38
+ return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_TIMEOUT_MS;
39
+ };
40
+
13
41
  module.exports = ({
14
42
  req,
15
43
  token,
@@ -32,17 +60,24 @@ module.exports = ({
32
60
  uri: process.env.IDENTITYX_GRAPHQL_URI || 'https://apis.mindfulcms.com/graphql/idx-compat',
33
61
  fetch,
34
62
  headers,
63
+ // Spread the caller's options last so an explicit per-client timeout wins.
64
+ fetchOptions: { timeout: getTimeoutMs(), ...linkConfig.fetchOptions },
35
65
  });
36
66
  const contextLink = setContext((_, { apiToken }) => ({
37
67
  headers: { ...(apiToken && { authorization: `OrgUserApiToken ${apiToken}` }) },
38
68
  }));
39
69
 
40
- return new ApolloClient({
41
- ...config,
42
- ...rootConfig,
43
- link: contextLink.concat(httpLink),
44
- cache: new InMemoryCache({
45
- fragmentMatcher: new IntrospectionFragmentMatcher({ introspectionQueryResultData }),
70
+ // Per-operation timing + trace segments. Paired with the timeout above: the
71
+ // timeout stops a stall being invisible, this says which operation stalled.
72
+ return instrumentApolloClient({
73
+ api: 'idx',
74
+ client: new ApolloClient({
75
+ ...config,
76
+ ...rootConfig,
77
+ link: contextLink.concat(httpLink),
78
+ cache: new InMemoryCache({
79
+ fragmentMatcher: new IntrospectionFragmentMatcher({ introspectionQueryResultData }),
80
+ }),
46
81
  }),
47
82
  });
48
83
  };