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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,27 @@ 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
+ `idxGoogleStepMs` records elapsed ms at the start of each step, making the route self-profiling:
154
+ `FACET idxGoogleStep SELECT max(idxGoogleStepMs)` shows where the ~12s on a *successful* sign-in
155
+ goes, and a request that dies mid-flight still reports how far it got. Responds `{ ok, requiresUserInput,
108
156
  redirectTo, user, entity, additionalEventData, … }`; the client redirects to `/user/profile` when
109
157
  input is required.
110
158
 
@@ -170,7 +218,7 @@ meter / gate.
170
218
  **Telemetry.** Two seams. Neither needs site configuration:
171
219
  - `config.addRequestAttributes` — called with a flat object as the route progresses
172
220
  (`idxGoogleStep`, `idxUserId`, `idxCreatedNewUser`, `idxRequiresUserInput`, `idxGoogleSurface`,
173
- `idxGoogleMissingFieldsBehavior`, `idxAutoSignupCount`). **Defaults to `utils/add-custom-attributes.js`**, which tags
221
+ `idxGoogleMissingFieldsBehavior`, `idxAutoSignupCount`, `idxImpersonateAttempts`). **Defaults to `utils/add-custom-attributes.js`**, which tags
174
222
  the current New Relic transaction, so the attributes ride on the `Transaction` *and* any
175
223
  `TransactionError` it produces — a 500 says which step failed instead of being a bare GraphQL
176
224
  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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0",
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": "564bbee92cbb8f659f8fc6e7cf8991f8d647dec3"
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,29 @@ 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
+ /**
37
+ * Transport-level failures only — a timeout, reset or DNS blip is worth
38
+ * repeating; a GraphQL validation or authorization error is not, and repeating
39
+ * it only doubles the user's wait before the identical 500.
40
+ *
41
+ * Apollo wraps transport failures as `networkError`; `node-fetch` raises its own
42
+ * timeout as a `FetchError` reading "network timeout at: <uri>".
43
+ */
44
+ const isRetryableIdxError = (e) => {
45
+ if (!e) return false;
46
+ if (e.networkError) return true;
47
+ if (e.name === 'FetchError' || e.type === 'request-timeout') return true;
48
+ return /network timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up/i
49
+ .test(String(e.message));
50
+ };
51
+
29
52
  // Namespace under which the Google account id (the JWT `sub`) is stored as an
30
53
  // idx external id, mirroring the Omeda { provider, tenant, type } convention.
31
54
  // These values must remain stable — changing them orphans existing links.
@@ -63,8 +86,19 @@ module.exports = asyncRoute(async (req, res) => {
63
86
  // Tag the transaction as the route progresses. Whatever step is recorded when
64
87
  // the request dies is the step that failed, so an error arrives already
65
88
  // localized instead of being a bare GraphQL message to reverse-engineer.
89
+ //
90
+ // `idxGoogleStepMs` is the elapsed time when each step *began*, which makes the
91
+ // route self-profiling: `FACET idxGoogleStep` with `max(idxGoogleStepMs)` shows
92
+ // where the time goes, and a request that dies mid-flight still reports how far
93
+ // it got and when. Sign-ins here average ~12s even when they succeed, and
94
+ // without this there is no way to tell which of the ~10 sequential upstream
95
+ // calls owns that.
96
+ const startedAt = Date.now();
66
97
  const addAttrs = config.get('addRequestAttributes') || (() => {});
67
- const setStep = (step) => addAttrs({ idxGoogleStep: step });
98
+ const setStep = (step) => addAttrs({
99
+ idxGoogleStep: step,
100
+ idxGoogleStepMs: Date.now() - startedAt,
101
+ });
68
102
 
69
103
  const { clientId, missingFieldsBehavior = 'profile-gate' } = config.getAsObject('googleAuth');
70
104
  // `surface` is the GIS `select_by` bucket ('button' | 'prompt'), sent by the
@@ -153,18 +187,60 @@ module.exports = asyncRoute(async (req, res) => {
153
187
  setStep('impersonate-app-user');
154
188
  // Impersonate the user with verify: true — marks them as verified and returns
155
189
  // 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;
190
+ //
191
+ // Retried once, because this is precisely where new-user sign-ins were
192
+ // observed to die. On overdriveonline.com in 2026-07, 15 of 15 requests that
193
+ // had just created the app user stalled inside this mutation (avg 53.1s, max
194
+ // 60.018s — an upstream 60s idle timeout) and never wrote a response, so the
195
+ // browser showed nothing at all and the user had to trigger One Tap a second
196
+ // time. Every hand-retry then succeeded, with `createdNewUser: false`. That
197
+ // correlation — stalls only when impersonating a user created milliseconds
198
+ // earlier — points at a read-after-write race on the idx API, which is
199
+ // exactly the shape a single retry fixes.
200
+ //
201
+ // `create-client.js` now bounds each call, so the stall arrives as a
202
+ // FetchError in ~15s rather than hanging; worst case here is two attempts and
203
+ // then a 500 the client can actually surface, instead of a 60s silent no-op.
204
+ let impersonateData;
205
+ let impersonateAttempts = 0;
206
+ for (;;) {
207
+ impersonateAttempts += 1;
208
+ try {
209
+ // eslint-disable-next-line no-await-in-loop
210
+ ({ data: impersonateData } = await identityX.client.mutate({
211
+ mutation: IMPERSONATE_APP_USER,
212
+ variables: {
213
+ input: {
214
+ id: appUser.id,
215
+ method: 'GOOGLE_ONE_TAP',
216
+ verify: true,
217
+ },
218
+ },
219
+ context: { apiToken: identityX.getOrgUserApiToken() },
220
+ }));
221
+ break;
222
+ } catch (e) {
223
+ // Only transport failures are worth repeating. A GraphQL validation or
224
+ // authorization error fails identically every time, so retrying it just
225
+ // doubles the user's wait before the same 500.
226
+ if (impersonateAttempts >= IMPERSONATE_MAX_ATTEMPTS || !isRetryableIdxError(e)) {
227
+ addAttrs({ idxImpersonateAttempts: impersonateAttempts });
228
+ throw e;
229
+ }
230
+ setStep('impersonate-app-user-retry');
231
+ // eslint-disable-next-line no-await-in-loop
232
+ await sleep(IMPERSONATE_RETRY_DELAY_MS);
233
+ }
234
+ }
235
+ addAttrs({ idxImpersonateAttempts: impersonateAttempts });
236
+
237
+ // Guard the destructure: a null payload here previously became an opaque
238
+ // "cannot destructure" TypeError several frames from the real cause.
239
+ const impersonated = impersonateData && impersonateData.impersonateAppUser;
240
+ if (!impersonated || !impersonated.token) {
241
+ throw new Error('Google sign-in could not establish a session: impersonation returned no token.');
242
+ }
243
+ const { token: authToken } = impersonated;
168
244
 
169
245
  tokenCookie.setTo(res, authToken.value);
170
246
  contextCookie.setTo(res, { loginSource: LOGIN_SOURCE });
@@ -241,13 +317,33 @@ module.exports = asyncRoute(async (req, res) => {
241
317
  // the same way they do for email-link users. This also populates
242
318
  // user.externalIds with the Omeda encryptedCustomerId so the subsequent
243
319
  // onAuthenticationSuccess hook can set oly_enc_id in the response.
320
+ //
321
+ // `forceAwait` because everything below *depends* on this hook's side effects,
322
+ // and `shouldAwait.onLoginLinkSent` defaults to FALSE — so by default these
323
+ // hooks were fire-and-forget and the dependency was a race this route lost
324
+ // most of the time.
325
+ //
326
+ // That default is correct for the email-link flow and wrong here, because the
327
+ // two flows have different shapes. Email-link fires onLoginLinkSent on the
328
+ // `/login` request when the link is *sent*, and onAuthenticationSuccess on a
329
+ // separate request minutes later when the user clicks it — rapid-identify has
330
+ // long since finished, so not awaiting it costs nothing. One Tap collapses both
331
+ // into a single request milliseconds apart. Un-awaited, Omeda rapid-identify
332
+ // has usually not yet written the `encryptedCustomerId` external id when
333
+ // `appendSubscriptions` looks for it, so it bails, produces no `autoSignups`,
334
+ // and no `subscribe` conversion is ever reported — the exact symptom seen on
335
+ // overdriveonline.com, where every completed One Tap sign-in recorded
336
+ // `idxAutoSignupCount: 0`.
337
+ //
338
+ // Bounded by the Omeda client's own request timeout, so awaiting cannot hang
339
+ // the route the way it could before that existed.
244
340
  setStep('hooks-login-link-sent');
245
341
  await callHooksFor(identityX, 'onLoginLinkSent', {
246
342
  req,
247
343
  source: LOGIN_SOURCE,
248
344
  user,
249
345
  additionalEventData: linkSentEventData,
250
- });
346
+ }, { forceAwait: true });
251
347
 
252
348
  // Re-fetch so onAuthenticationSuccess sees the externalIds written by
253
349
  // 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
  };