@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 +71 -2
- package/components/access.marko.js +2 -2
- package/components/comment-stream.marko.js +2 -2
- package/components/context.marko.js +2 -2
- package/components/form-access.marko.js +2 -2
- package/components/form-authenticate.marko.js +2 -2
- package/components/form-change-email.marko.js +2 -2
- package/components/form-login.marko.js +2 -2
- package/components/form-logout.marko.js +2 -2
- package/components/form-profile.marko.js +2 -2
- package/components/form-register.marko.js +2 -2
- package/components/google-init.marko.js +2 -2
- package/components/google-sign-in-button.marko.js +2 -2
- package/components/identify.marko.js +2 -2
- package/components/non-auth-identify.marko.js +2 -2
- package/components/subscribe.marko.js +2 -2
- package/package.json +5 -5
- package/routes/google.js +178 -19
- package/utils/call-hooks-for.js +19 -5
- package/utils/create-client.js +41 -6
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`).
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
17
|
-
"@mindful-web/object-path": "^1.
|
|
18
|
-
"@mindful-web/utils": "^1.
|
|
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": "
|
|
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({
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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).
|
package/utils/call-hooks-for.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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)
|
|
41
|
+
if (promises.wait.length) await Promise.all(promises.wait);
|
|
28
42
|
};
|
package/utils/create-client.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
};
|