@mindful-web/marko-web-identity-x 1.83.0 → 1.85.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 +301 -7
- package/api/mutations/progressive-profile-update.js +29 -0
- package/browser/index.js +13 -0
- package/browser/progressive-profile.vue +388 -0
- 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/marko.json +16 -0
- package/components/non-auth-identify.marko +6 -1
- package/components/non-auth-identify.marko.js +3 -3
- package/components/progressive-profile-form.marko +106 -0
- package/components/progressive-profile-form.marko.js +138 -0
- package/components/subscribe.marko.js +2 -2
- package/config.js +98 -0
- package/package.json +6 -5
- package/routes/google.js +5 -0
- package/routes/index.js +3 -0
- package/routes/progressive-profile.js +296 -0
- package/service.js +31 -0
- package/utils/add-custom-attributes.js +19 -46
- package/utils/build-progressive-profile-payload.js +117 -0
- package/utils/get-missing-progressive-fields.js +114 -0
- package/utils/get-progressive-form-fields.js +67 -0
- package/utils/progressive-profile-cookies.js +120 -0
- package/utils/progressive-profile-telemetry.js +148 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
const { verifyToken } = require('@mindful-web/marko-web-recaptcha');
|
|
2
|
+
const { asyncRoute } = require('@mindful-web/utils');
|
|
3
|
+
const { getAsArray } = require('@mindful-web/object-path');
|
|
4
|
+
const PROGRESSIVE_PROFILE_UPDATE = require('../api/mutations/progressive-profile-update');
|
|
5
|
+
const buildPayload = require('../utils/build-progressive-profile-payload');
|
|
6
|
+
const callHooksFor = require('../utils/call-hooks-for');
|
|
7
|
+
const cookies = require('../utils/progressive-profile-cookies');
|
|
8
|
+
const { EVENT_TYPES, createRecorder } = require('../utils/progressive-profile-telemetry');
|
|
9
|
+
|
|
10
|
+
const { buildContentSource } = buildPayload;
|
|
11
|
+
|
|
12
|
+
const ACTION_SOURCE = 'progressiveProfile';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The reCAPTCHA v3 action for this form. Declared client-side and checked server-side, so a token
|
|
16
|
+
* minted for some other form on the site cannot be replayed here.
|
|
17
|
+
*/
|
|
18
|
+
const RECAPTCHA_ACTION = 'progressiveProfileSubmit';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Score floor. The package default is 0.5, which is what every other form on the fleet uses.
|
|
22
|
+
* Overridable per site via `progressiveProfile.recaptchaMinimumScore` on the IdentityX config.
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_MINIMUM_SCORE = 0.5;
|
|
25
|
+
|
|
26
|
+
const DEFAULTS = {
|
|
27
|
+
suppressAfterDismissDays: 0,
|
|
28
|
+
unverifiedSuppressDays: cookies.DEFAULT_UNVERIFIED_SUPPRESS_DAYS,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const optionFor = (config, key) => config.get(`progressiveProfile.${key}`, DEFAULTS[key]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `POST /__idx/progressive-profile`
|
|
35
|
+
*
|
|
36
|
+
* A thin proxy over the single `progressiveProfileUpdate` mutation. It holds **no write logic of
|
|
37
|
+
* its own**: the re-diff that drops already-filled fields, the attribute writes, the
|
|
38
|
+
* `action-history` attribution and the `PROGRESSIVE_PROFILE_SUBMIT` submission event all happen
|
|
39
|
+
* inside that mutation's transaction.
|
|
40
|
+
*
|
|
41
|
+
* What this route *does* own is trust: which member is being written to (never `body.userId`),
|
|
42
|
+
* which fields may be written (server config, never the body), and reCAPTCHA.
|
|
43
|
+
*
|
|
44
|
+
* ## Bot protection — reCAPTCHA, and what a failure actually does
|
|
45
|
+
*
|
|
46
|
+
* The threat is concrete. `marko-web-omeda-identity-x/middleware/set-identity-cookie.js` mints
|
|
47
|
+
* `__idx_idt` for a **real person's** AppUser id on any request carrying `oly_enc_id`, so a
|
|
48
|
+
* newsletter security scanner following an emailed link browses *as* an identified human and could
|
|
49
|
+
* write to a real record. Cloudflare on these sites segregates rather than drops, so there is no
|
|
50
|
+
* upstream to lean on.
|
|
51
|
+
*
|
|
52
|
+
* Verification is the fleet standard — reCAPTCHA v3 via `@mindful-web/marko-web-recaptcha`, the
|
|
53
|
+
* same package and score floor behind newsletter signup, contact-us, inquiries and the RigDig
|
|
54
|
+
* checkout. What differs is the **response to a failure**.
|
|
55
|
+
*
|
|
56
|
+
* **A failed check never rejects the visitor. It silently drops the write.** The submission
|
|
57
|
+
* "succeeds", the content unlocks, and the answers go nowhere. Rejecting instead would be strictly
|
|
58
|
+
* worse: this gate stands between a reader and an article, so a false positive locks a real person
|
|
59
|
+
* out of content they can otherwise reach — a bigger loss than the bot write it prevented. A bot,
|
|
60
|
+
* meanwhile, gains only an article it could largely have read anonymously, and gains no write at
|
|
61
|
+
* all, which is the only thing actually at stake.
|
|
62
|
+
*
|
|
63
|
+
* Two consequences that are easy to miss:
|
|
64
|
+
*
|
|
65
|
+
* - **A suppression cookie is mandatory, not a nicety.** The answers were discarded, so the
|
|
66
|
+
* member's record is unchanged and the diff would gate them again on the next article, forever.
|
|
67
|
+
* `setUnverifiedSuppressed` stops that and hands them back to the content meter.
|
|
68
|
+
* - **A false positive is invisible to everyone.** The visitor is never told, and we would never
|
|
69
|
+
* know either — which is why the score is recorded on *every* submit, passing or not. That
|
|
70
|
+
* telemetry is what makes this shape safe to ship; see `CLAUDE.md` for the decision rule it
|
|
71
|
+
* feeds.
|
|
72
|
+
*
|
|
73
|
+
* **One rule for every visitor.** A failed check drops the write whether or not a session is
|
|
74
|
+
* present. An earlier revision exempted session holders on the grounds that `/user/profile` has no
|
|
75
|
+
* captcha either, so a session is already sufficient authorization for a profile write elsewhere in
|
|
76
|
+
* this package — but that is a user-initiated edit on a page someone navigated to, while this is an
|
|
77
|
+
* interstitial pushed at a link-follower, which is exactly what an email scanner hits. Scanners do
|
|
78
|
+
* follow magic links, so a session is not proof of a human.
|
|
79
|
+
*
|
|
80
|
+
* The exemption also bought nothing the uniform rule does not: the score is recorded on every
|
|
81
|
+
* submit regardless, so authenticated submits still serve as the known-human control group that
|
|
82
|
+
* says whether the threshold itself is wrong. Only the write differs, and passive accept means the
|
|
83
|
+
* visitor keeps the content either way.
|
|
84
|
+
*
|
|
85
|
+
* The hard damage bound is not here at all: the mutation re-diffs inside its transaction and can
|
|
86
|
+
* only ever fill a field that was already empty, so nothing that gets past reCAPTCHA can destroy
|
|
87
|
+
* data. Every write is also recorded in `action-history` with its `input`/`result`, so a bad one is
|
|
88
|
+
* findable and reversible from one collection.
|
|
89
|
+
*/
|
|
90
|
+
const submit = asyncRoute(async (req, res) => {
|
|
91
|
+
/** @type {import('../middleware').IdentityXRequest} */
|
|
92
|
+
const { identityX, body = {} } = req;
|
|
93
|
+
const { config } = identityX;
|
|
94
|
+
const onError = config.get('onHookError') || (() => {});
|
|
95
|
+
|
|
96
|
+
// Telemetry: exactly one event per submit, typed by what happened -- `ProgressiveProfileSubmit`,
|
|
97
|
+
// `Discarded`, `Rejected` or `Error`. Each terminal branch below records its own, which is what
|
|
98
|
+
// makes the outcomes separately countable.
|
|
99
|
+
//
|
|
100
|
+
// The arithmetic is guaranteed by the recorder, not by whoever edits this next:
|
|
101
|
+
// `record` is a no-op once anything has fired (so no path can double count), and the `finally`
|
|
102
|
+
// at the bottom is a backstop for a path that recorded nothing. If you add a branch and forget
|
|
103
|
+
// the call, it does not go missing -- it surfaces as `ProgressiveProfileError` with
|
|
104
|
+
// `reason: 'unrecorded'`.
|
|
105
|
+
//
|
|
106
|
+
// `telemetry.set(...)` accumulates the attributes shared by whichever type ends up firing, so a
|
|
107
|
+
// call site only names what is specific to it.
|
|
108
|
+
const telemetry = createRecorder(req);
|
|
109
|
+
telemetry.set({ contentId: body.contentId == null ? null : `${body.contentId}` });
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// --- Verify, but do not reject. -------------------------------------------------------------
|
|
113
|
+
const {
|
|
114
|
+
success, score, action, errorCodes,
|
|
115
|
+
} = await verifyToken({
|
|
116
|
+
token: body.recaptchaToken,
|
|
117
|
+
secretKey: config.get(
|
|
118
|
+
'progressiveProfile.recaptchaSecretKey',
|
|
119
|
+
process.env.RECAPTCHA_V3_SECRET_KEY,
|
|
120
|
+
),
|
|
121
|
+
});
|
|
122
|
+
const minimumScore = config.get('progressiveProfile.recaptchaMinimumScore', DEFAULT_MINIMUM_SCORE);
|
|
123
|
+
|
|
124
|
+
// Each outcome is recorded separately. `no-token` in volume means blocked scripts, not bots;
|
|
125
|
+
// `wrong-action` means a token minted for another form on the site; `low-score` is the only one
|
|
126
|
+
// whose rate is a judgement call, and the only one the score can explain.
|
|
127
|
+
const outcome = (() => {
|
|
128
|
+
if (!body.recaptchaToken) return 'no-token';
|
|
129
|
+
if (!success) return errorCodes.includes('timeout-or-duplicate') ? 'stale-token' : 'bad-token';
|
|
130
|
+
if (action !== RECAPTCHA_ACTION) return 'wrong-action';
|
|
131
|
+
// `score != null` mirrors `validateToken`: a success carrying no score is not a low score.
|
|
132
|
+
if (score != null && score < minimumScore) return 'low-score';
|
|
133
|
+
return 'passed';
|
|
134
|
+
})();
|
|
135
|
+
// Recorded on every submit, including passes -- a rejection rate is unactionable without the
|
|
136
|
+
// distribution behind it, and the passing scores are the baseline it has to be read against.
|
|
137
|
+
telemetry.set({ recaptcha: outcome, ...(score != null && { recaptchaScore: score }) });
|
|
138
|
+
|
|
139
|
+
// --- Target resolution. `body.userId` is never trusted. -------------------------------------
|
|
140
|
+
const { user: authUser } = await identityX.loadActiveContext();
|
|
141
|
+
const targetId = authUser ? authUser.id : identityX.getIdentity(res);
|
|
142
|
+
if (!targetId) {
|
|
143
|
+
telemetry.record(EVENT_TYPES.REJECTED, { reason: 'no-identity' });
|
|
144
|
+
res.status(401).json({ ok: false, message: 'No identity present.' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!authUser && body.userId && `${body.userId}` !== `${targetId}`) {
|
|
148
|
+
telemetry.record(EVENT_TYPES.REJECTED, { reason: 'identity-mismatch' });
|
|
149
|
+
res.status(403).json({ ok: false, message: 'The supplied user does not match the current identity.' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isOrgAuth = !authUser;
|
|
154
|
+
telemetry.set({ userId: `${targetId}`, auth: isOrgAuth ? 'org' : 'user' });
|
|
155
|
+
|
|
156
|
+
// --- Passive accept: unlock the content, drop the write. ------------------------------------
|
|
157
|
+
// Checked before the payload is built, so a body we were never going to write is a quiet 200
|
|
158
|
+
// rather than a 400 explaining what a better-formed request would have looked like.
|
|
159
|
+
if (outcome !== 'passed') {
|
|
160
|
+
cookies.setUnverifiedSuppressed(res, optionFor(config, 'unverifiedSuppressDays'));
|
|
161
|
+
cookies.clearAttempts(res);
|
|
162
|
+
telemetry.record(EVENT_TYPES.DISCARDED);
|
|
163
|
+
// `changed: false` is what the GA4 conversion already filters on, so a discarded submit is
|
|
164
|
+
// excluded from completions without any GTM change. `accepted` makes it separately countable.
|
|
165
|
+
res.json({
|
|
166
|
+
ok: true, changed: false, accepted: 'unverified', user: null,
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- What may be written comes from server config, never the request. -----------------------
|
|
172
|
+
const definitions = config.getProgressiveProfileFieldRows()
|
|
173
|
+
.reduce((arr, row) => [...arr, ...getAsArray({ row }, 'row')], []);
|
|
174
|
+
const { selectAnswers, booleanAnswers, textAnswers } = buildPayload({ body, definitions });
|
|
175
|
+
const fieldCount = selectAnswers.length + booleanAnswers.length + textAnswers.length;
|
|
176
|
+
if (!fieldCount) {
|
|
177
|
+
// The mutation throws a `UserInputError` for this anyway; catching it here keeps a client bug
|
|
178
|
+
// out of the audience API's error log.
|
|
179
|
+
telemetry.record(EVENT_TYPES.REJECTED, { reason: 'no-answers', fieldCount });
|
|
180
|
+
res.status(400).json({ ok: false, message: 'At least one answer is required.' });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { data } = await identityX.client.mutate({
|
|
185
|
+
mutation: PROGRESSIVE_PROFILE_UPDATE,
|
|
186
|
+
variables: {
|
|
187
|
+
input: {
|
|
188
|
+
// Ignored on the AppUser path; required on the org path. Sent from the *resolved* target,
|
|
189
|
+
// not the body.
|
|
190
|
+
...(isOrgAuth && { userId: targetId }),
|
|
191
|
+
...(selectAnswers.length && { selectAnswers }),
|
|
192
|
+
...(booleanAnswers.length && { booleanAnswers }),
|
|
193
|
+
...(textAnswers.length && { textAnswers }),
|
|
194
|
+
// `contentId` from the body, as `routes/access.js` already does. The mutation
|
|
195
|
+
// validates it is exactly 8 digits and belongs to the request's own tenant before
|
|
196
|
+
// deriving `postId`, so the worst a wrong value can do is misattribute one submission
|
|
197
|
+
// event -- it cannot reach another tenant's content or change who is written to.
|
|
198
|
+
source: buildContentSource({
|
|
199
|
+
config,
|
|
200
|
+
contentId: body.contentId,
|
|
201
|
+
actionSource: ACTION_SOURCE,
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
// An authenticated visitor writes as themselves; an identified one is written by the org.
|
|
206
|
+
...(isOrgAuth && { context: { apiToken: identityX.getOrgUserApiToken() } }),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const { ok, changed, appUser } = data.progressiveProfileUpdate;
|
|
210
|
+
|
|
211
|
+
// Release the escape valve. Cleared on any success, not only on `changed: true` -- a no-op
|
|
212
|
+
// submit means the fields are already filled, so the next render will not gate anyway and a
|
|
213
|
+
// stale counter would only shorten someone else's runway.
|
|
214
|
+
cookies.clearAttempts(res);
|
|
215
|
+
|
|
216
|
+
// --- Downstream profile-update hooks. --------------------------------------------------------
|
|
217
|
+
// `onUserProfileUpdate` is provider-agnostic. Omeda rapid-identify is the most visible consumer
|
|
218
|
+
// but it is one of five: `marko-web-omeda-identity-x`, `marko-web-identity-x-mailchimp`,
|
|
219
|
+
// `marko-web-postup`, plus site-level packages for Braze and ActiveCampaign. Which of them runs
|
|
220
|
+
// is purely a function of what a site installed.
|
|
221
|
+
//
|
|
222
|
+
// Skipped when nothing changed (nothing to sync) and never fatal: the audience write has
|
|
223
|
+
// already committed, so failing the request here would report a loss that did not happen.
|
|
224
|
+
//
|
|
225
|
+
// Deliberately NOT `onLoginLinkSent` / `onAuthenticationSuccess`. Those drive auto-signup and
|
|
226
|
+
// the GA4 `subscribe` conversion; this is not a registration, and firing them reports phantom
|
|
227
|
+
// subscriptions.
|
|
228
|
+
//
|
|
229
|
+
// `dispatched` means the queue was fired -- NOT that any hook succeeded, and do not "correct"
|
|
230
|
+
// it to `ok`. Every registrant uses `shouldAwait: false`, so `callHooksFor` returns before the
|
|
231
|
+
// hooks have done anything, and `utils/call-hooks-for.js` wraps each promise in
|
|
232
|
+
// `.catch(onHookError)` so it essentially never rejects. An async failure -- the realistic one,
|
|
233
|
+
// e.g. Omeda timing out seconds later -- is reported there and can never reach this field.
|
|
234
|
+
// `error` is therefore a *synchronous* throw from dispatch only: guarded because the write has
|
|
235
|
+
// already committed and an unhandled throw would 500 a request whose data actually landed, and
|
|
236
|
+
// reported locally because `call-hooks-for` only catches returned promises, so this is not a
|
|
237
|
+
// double report.
|
|
238
|
+
let profileUpdateHooks = 'skipped';
|
|
239
|
+
if (changed && appUser) {
|
|
240
|
+
try {
|
|
241
|
+
await callHooksFor(identityX, 'onUserProfileUpdate', {
|
|
242
|
+
req,
|
|
243
|
+
user: appUser,
|
|
244
|
+
additionalEventData: { actionSource: ACTION_SOURCE },
|
|
245
|
+
actionSource: ACTION_SOURCE,
|
|
246
|
+
});
|
|
247
|
+
profileUpdateHooks = 'dispatched';
|
|
248
|
+
} catch (e) {
|
|
249
|
+
profileUpdateHooks = 'error';
|
|
250
|
+
onError(e, { idxProgressiveProfileStep: 'onUserProfileUpdate', idxUserId: targetId });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
telemetry.record(EVENT_TYPES.SUBMIT, {
|
|
255
|
+
fieldCount,
|
|
256
|
+
changed: Boolean(changed),
|
|
257
|
+
profileUpdateHooks,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// No `actionId` and no `entity`: the submit is recorded server-side and the browser has no
|
|
261
|
+
// tracking left to do on it. `changed` is server-truth and is what the GA4 conversion
|
|
262
|
+
// filters on. `accepted` here is the client contract the dataLayer event reads -- unrelated to
|
|
263
|
+
// the New Relic event type above, which carries the same fact for reporting.
|
|
264
|
+
res.json({
|
|
265
|
+
ok: Boolean(ok), changed: Boolean(changed), accepted: 'written', user: appUser || null,
|
|
266
|
+
});
|
|
267
|
+
} catch (e) {
|
|
268
|
+
// Record before rethrowing, so a failure is a row rather than a gap. The error handler still
|
|
269
|
+
// gets the throw; this only observes it. Message only -- submitted answers never go to New
|
|
270
|
+
// Relic, and the mutation's errors are generic by design.
|
|
271
|
+
telemetry.record(EVENT_TYPES.ERROR, { error: `${e.name}: ${e.message}` });
|
|
272
|
+
throw e;
|
|
273
|
+
} finally {
|
|
274
|
+
// Backstop, and a no-op on every path above because they all recorded. It fires only when a
|
|
275
|
+
// branch returned without one -- and it says so, rather than guessing a type, so the gap is
|
|
276
|
+
// visible in New Relic instead of silently skewing the mix.
|
|
277
|
+
telemetry.record(EVENT_TYPES.ERROR, { reason: 'unrecorded' });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `POST /__idx/progressive-profile/dismiss`
|
|
283
|
+
*
|
|
284
|
+
* Only meaningful when a site opts into `dismissible` + `suppressAfterDismissDays`; with the
|
|
285
|
+
* default hard-gate cadence there is no skip affordance to reach it and the cookie is never set.
|
|
286
|
+
*
|
|
287
|
+
* No reCAPTCHA: this writes nothing but a cookie on the caller's own browser.
|
|
288
|
+
*/
|
|
289
|
+
const dismiss = asyncRoute(async (req, res) => {
|
|
290
|
+
/** @type {import('../middleware').IdentityXRequest} */
|
|
291
|
+
const { identityX } = req;
|
|
292
|
+
const dismissed = cookies.setDismissed(res, optionFor(identityX.config, 'suppressAfterDismissDays'));
|
|
293
|
+
res.json({ ok: true, dismissed });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
module.exports = { RECAPTCHA_ACTION, dismiss, submit };
|
package/service.js
CHANGED
|
@@ -354,6 +354,37 @@ class IdentityX {
|
|
|
354
354
|
return data.appUserById;
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
/**
|
|
358
|
+
* `findUserById`, memoized for the life of the request.
|
|
359
|
+
*
|
|
360
|
+
* A gated page load asks for the same record up to three times — the progressive-profile
|
|
361
|
+
* middleware, `getIdentityData()`, and `non-auth-identify.marko` — for one identity that cannot
|
|
362
|
+
* change mid-request. This collapses them to a single round trip.
|
|
363
|
+
*
|
|
364
|
+
* **Deliberately a separate method rather than caching `findUserById` itself.**
|
|
365
|
+
* `routes/google.js` calls `findUserById` twice on purpose, either side of the
|
|
366
|
+
* `onLoginLinkSent` hook, precisely to observe the `externalIds` that hook writes — memoizing
|
|
367
|
+
* the shared method would hand it stale data and silently drop the Omeda `oly_enc_id` from the
|
|
368
|
+
* response. Only read paths that perform no writes may use this.
|
|
369
|
+
*
|
|
370
|
+
* @param {String} id The user id
|
|
371
|
+
* @returns {Promise<User|null>}
|
|
372
|
+
*/
|
|
373
|
+
async findUserByIdMemoized(id) {
|
|
374
|
+
if (!id) return null;
|
|
375
|
+
if (!this.userByIdQueries) this.userByIdQueries = new Map();
|
|
376
|
+
if (!this.userByIdQueries.has(id)) {
|
|
377
|
+
// The *promise* is cached, not its result, so concurrent callers share one in-flight query
|
|
378
|
+
// rather than each starting their own.
|
|
379
|
+
this.userByIdQueries.set(id, this.findUserById(id).catch((e) => {
|
|
380
|
+
// A failed lookup must not be cached as a permanent failure for the rest of the request.
|
|
381
|
+
this.userByIdQueries.delete(id);
|
|
382
|
+
throw e;
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
return this.userByIdQueries.get(id);
|
|
386
|
+
}
|
|
387
|
+
|
|
357
388
|
/**
|
|
358
389
|
*
|
|
359
390
|
* @param {object} params
|
|
@@ -1,50 +1,23 @@
|
|
|
1
|
+
const { newrelicAgent } = require('@mindful-web/utils');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Default sink for `config.addRequestAttributes` — tags the current New Relic
|
|
3
|
-
*
|
|
4
|
+
* Default sink for `config.addRequestAttributes` — tags the current New Relic transaction.
|
|
5
|
+
*
|
|
6
|
+
* A thin delegation to `@mindful-web/utils`, which owns the agent resolution: it uses the agent
|
|
7
|
+
* **only when the host application has already loaded it** (a `require.cache` check rather than a
|
|
8
|
+
* try/caught require, because requiring `newrelic` cold would *start* an agent on sites that don't
|
|
9
|
+
* use it). Net effect is unchanged — attributes arrive fleet-wide on a dependency bump and the
|
|
10
|
+
* whole thing is inert without New Relic — and it swallows every error, because telemetry must
|
|
11
|
+
* never break a request.
|
|
12
|
+
*
|
|
13
|
+
* This file used to carry its own copy of that resolution logic, line for line. It is gone:
|
|
14
|
+
* identity-x already depends on `@mindful-web/utils` directly *and* transitively
|
|
15
|
+
* (`utils/create-client.js` → `instrumentApolloClient` → `newrelic-agent`), so the duplicate was
|
|
16
|
+
* two copies that could only ever drift apart.
|
|
4
17
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* is served the module is in `require.cache` and `require('newrelic')` returns
|
|
8
|
-
* that same initialized singleton. Requiring it unconditionally would instead
|
|
9
|
-
* *start* an agent on sites that don't use New Relic, which is why this checks
|
|
10
|
-
* the cache rather than just try/catching the require.
|
|
18
|
+
* A site can still pass its own `addRequestAttributes` to override this entirely. For telemetry
|
|
19
|
+
* that should **not** ride on the transaction, see `utils/progressive-profile-telemetry.js`.
|
|
11
20
|
*
|
|
12
|
-
*
|
|
13
|
-
* whole thing is inert on any site without New Relic. A site can still pass its
|
|
14
|
-
* own `addRequestAttributes` to override.
|
|
21
|
+
* @param {object} attributes Flat key/values only — New Relic drops nested objects.
|
|
15
22
|
*/
|
|
16
|
-
|
|
17
|
-
let resolvedPath;
|
|
18
|
-
let lookupFailed = false;
|
|
19
|
-
let agent = null;
|
|
20
|
-
|
|
21
|
-
const getAgent = () => {
|
|
22
|
-
if (agent) return agent;
|
|
23
|
-
if (lookupFailed) return null;
|
|
24
|
-
if (!resolvedPath) {
|
|
25
|
-
try {
|
|
26
|
-
resolvedPath = require.resolve('newrelic');
|
|
27
|
-
} catch (e) {
|
|
28
|
-
// Not installed — nothing to report to.
|
|
29
|
-
lookupFailed = true;
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
// `newrelic` is intentionally NOT a dependency of this package: declaring it
|
|
34
|
-
// would install it on every monorail site, and requiring an uninitialized
|
|
35
|
-
// agent starts one. We only ever pick up an instance the host app already
|
|
36
|
-
// created, which is why the cache check above guards this require.
|
|
37
|
-
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
38
|
-
if (require.cache[resolvedPath]) agent = require('newrelic');
|
|
39
|
-
return agent;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
module.exports = (attributes) => {
|
|
43
|
-
const nr = getAgent();
|
|
44
|
-
if (!nr || typeof nr.addCustomAttributes !== 'function') return;
|
|
45
|
-
try {
|
|
46
|
-
nr.addCustomAttributes(attributes);
|
|
47
|
-
} catch (e) {
|
|
48
|
-
// Telemetry must never break a request.
|
|
49
|
-
}
|
|
50
|
-
};
|
|
23
|
+
module.exports = (attributes) => newrelicAgent.addCustomAttributes(attributes);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const { getAsArray } = require('@mindful-web/object-path');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Locates the submitted answer for a definition.
|
|
5
|
+
*
|
|
6
|
+
* `get-progressive-form-fields.js` stamps `id` with the **field** id (the contract
|
|
7
|
+
* `custom-column.vue` reads), so that is the primary match. `field.id` is accepted as a fallback
|
|
8
|
+
* for an answer that arrived straight off an idx record without passing through that util.
|
|
9
|
+
*/
|
|
10
|
+
const findSubmitted = (answers, fieldId) => answers.find((answer) => answer && (
|
|
11
|
+
answer.id === fieldId || (answer.field && answer.field.id === fieldId)
|
|
12
|
+
));
|
|
13
|
+
|
|
14
|
+
const isBlank = (value) => value == null || `${value}`.trim() === '';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Marshals a submitted progressive-profile form body into `progressiveProfileUpdate` variables.
|
|
18
|
+
*
|
|
19
|
+
* Three things it deliberately does **not** do:
|
|
20
|
+
*
|
|
21
|
+
* - **Decide what is missing.** The mutation re-diffs every answer against the member's live
|
|
22
|
+
* record inside its own transaction and drops anything already filled, so a stale form cannot
|
|
23
|
+
* overwrite good data and this util does not need to guarantee freshness.
|
|
24
|
+
* - **Trust the request for field definitions.** `definitions` comes from server config
|
|
25
|
+
* (`config.getProgressiveProfileFieldRows()`), never from the body, so a client cannot widen
|
|
26
|
+
* what gets written by posting extra rows.
|
|
27
|
+
* - **Write built-ins.** The shipped mutation is attributes-only; there is no `builtIns` input.
|
|
28
|
+
* `config.js` refuses to boot with a `built-in` column for that reason.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} params
|
|
31
|
+
* @param {object} [params.body] The posted form body (`{ ...user }` from the Vue component).
|
|
32
|
+
* @param {import('..').CustomColumnDefinition[]} [params.definitions] Flattened, server-supplied.
|
|
33
|
+
* @returns {{ selectAnswers: object[], booleanAnswers: object[], textAnswers: object[] }}
|
|
34
|
+
*/
|
|
35
|
+
module.exports = ({ body, definitions = [] } = {}) => {
|
|
36
|
+
const selectAnswers = [];
|
|
37
|
+
const booleanAnswers = [];
|
|
38
|
+
const textAnswers = [];
|
|
39
|
+
|
|
40
|
+
const submitted = {
|
|
41
|
+
'custom-select': getAsArray(body, 'customSelectFieldAnswers'),
|
|
42
|
+
'custom-boolean': getAsArray(body, 'customBooleanFieldAnswers'),
|
|
43
|
+
'custom-text': getAsArray(body, 'customTextFieldAnswers'),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
definitions.forEach((col) => {
|
|
47
|
+
if (!col || !col.id) return;
|
|
48
|
+
const answer = findSubmitted(submitted[col.type] || [], col.id);
|
|
49
|
+
if (!answer) return;
|
|
50
|
+
|
|
51
|
+
switch (col.type) {
|
|
52
|
+
case 'custom-select': {
|
|
53
|
+
const options = getAsArray(answer, 'answers').filter((option) => option && option.id);
|
|
54
|
+
// An empty selection is not an answer. Sending it anyway would post `optionIds: []`,
|
|
55
|
+
// which the underlying writer reads as "unset" — the one way this feature could destroy
|
|
56
|
+
// data despite the mutation's fill-blanks-only rule.
|
|
57
|
+
if (!options.length) return;
|
|
58
|
+
selectAnswers.push({
|
|
59
|
+
fieldId: col.id,
|
|
60
|
+
optionIds: options.map(({ id }) => id),
|
|
61
|
+
writeInValues: options.reduce((arr, { id, writeInValue }) => ([
|
|
62
|
+
...arr,
|
|
63
|
+
...(isBlank(writeInValue) ? [] : [{ optionId: id, value: writeInValue }]),
|
|
64
|
+
]), []),
|
|
65
|
+
});
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case 'custom-boolean': {
|
|
69
|
+
// `hasAnswered` is the gate, not truthiness: an explicit `false` is an answer.
|
|
70
|
+
//
|
|
71
|
+
// Unreachable today — `config.js` rejects `custom-boolean` at boot because the shared
|
|
72
|
+
// `custom-column.vue` cannot render a boolean's answered state (it binds `answer` but the
|
|
73
|
+
// change handler writes `value`), which makes a *required* boolean an unsatisfiable gate.
|
|
74
|
+
// Kept in step with the diff util and the mutation so re-enabling it is one line once
|
|
75
|
+
// that shared bug is fixed.
|
|
76
|
+
if (!answer.hasAnswered) return;
|
|
77
|
+
const value = answer.value == null ? answer.answer : answer.value;
|
|
78
|
+
booleanAnswers.push({ fieldId: col.id, value: Boolean(value) });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'custom-text': {
|
|
82
|
+
if (isBlank(answer.value)) return;
|
|
83
|
+
textAnswers.push({ fieldId: col.id, value: `${answer.value}`.trim() });
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return { selectAnswers, booleanAnswers, textAnswers };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Assembles the mutation's required `source` object — where the gate was rendered.
|
|
96
|
+
*
|
|
97
|
+
* All four values are validated server-side: `legacyTenantKey` must resolve to a known legacy
|
|
98
|
+
* tenant **and** match the tenant the request resolved to, and `integerId` must be exactly 8
|
|
99
|
+
* digits (true of all 442 content routes across the 18 website repos). A mismatch therefore
|
|
100
|
+
* surfaces as a `UserInputError` rather than silently recording a wrong `postId`.
|
|
101
|
+
*
|
|
102
|
+
* `websiteChannelId` is the idx app id: the two are the same value, confirmed against the tenancy
|
|
103
|
+
* `websites` map and the audience `attributes.websiteChannelIds` data.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} params
|
|
106
|
+
* @param {import('../config')} params.config
|
|
107
|
+
* @param {string|number} params.contentId The base-CMS post integer id.
|
|
108
|
+
* @param {string} [params.actionSource]
|
|
109
|
+
*/
|
|
110
|
+
module.exports.buildContentSource = ({ config, contentId, actionSource } = {}) => ({
|
|
111
|
+
// The same env var `marko-web-p1-events/utils/create-namespace.js` reads, and `package.json`'s
|
|
112
|
+
// `website.tenant`.
|
|
113
|
+
legacyTenantKey: process.env.TENANT_KEY,
|
|
114
|
+
integerId: Number.parseInt(contentId, 10),
|
|
115
|
+
websiteChannelId: config.getAppId(),
|
|
116
|
+
...(actionSource && { actionSource }),
|
|
117
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { getAsArray } = require('@mindful-web/object-path');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The only column types progressive profiling can ask for.
|
|
5
|
+
*
|
|
6
|
+
* `built-in` is deliberately absent. The `progressiveProfileUpdate` mutation is **attributes
|
|
7
|
+
* only** — core/root member fields were cut from it during review — so a built-in column would
|
|
8
|
+
* render in the gate, accept a value, and then be silently unwritable: the user submits, the
|
|
9
|
+
* mutation reports `changed: false`, and the gate re-renders forever. `config.js` refuses to boot
|
|
10
|
+
* with one for that reason; this set is the second half of the same guard.
|
|
11
|
+
*/
|
|
12
|
+
const SUPPORTED_TYPES = new Set(['custom-select', 'custom-boolean', 'custom-text']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Finds a user's answer for a field definition.
|
|
16
|
+
*
|
|
17
|
+
* Matched on `answer.field.id`, **never** `answer.id`. An idx custom answer carries its own id —
|
|
18
|
+
* the id of the answer document, not of the field it answers — so comparing `answer.id` to a
|
|
19
|
+
* configured field id never matches. That is the live bug in `utils/get-form-custom-fields.js`
|
|
20
|
+
* (and in `config.getGTMUserData`), which is why this feature does not reuse either.
|
|
21
|
+
*
|
|
22
|
+
* @param {object[]} answers
|
|
23
|
+
* @param {string} fieldId
|
|
24
|
+
*/
|
|
25
|
+
const findAnswer = (answers, fieldId) => answers
|
|
26
|
+
.find((answer) => answer && answer.field && answer.field.id === fieldId);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a single configured column is unanswered on the given user.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} params
|
|
32
|
+
* @param {object} params.appUser
|
|
33
|
+
* @param {import('..').CustomColumnDefinition} params.col
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
const isColumnMissing = ({ appUser, col }) => {
|
|
37
|
+
const { id, type } = col;
|
|
38
|
+
// An unsupported or id-less column can never be written, so it must never gate anyone.
|
|
39
|
+
if (!id || !SUPPORTED_TYPES.has(type)) return false;
|
|
40
|
+
|
|
41
|
+
switch (type) {
|
|
42
|
+
case 'custom-select': {
|
|
43
|
+
const answer = findAnswer(getAsArray(appUser, 'customSelectFieldAnswers'), id);
|
|
44
|
+
if (!answer || !answer.hasAnswered) return true;
|
|
45
|
+
return !getAsArray(answer, 'answers').length;
|
|
46
|
+
}
|
|
47
|
+
case 'custom-text': {
|
|
48
|
+
const answer = findAnswer(getAsArray(appUser, 'customTextFieldAnswers'), id);
|
|
49
|
+
if (!answer || !answer.hasAnswered) return true;
|
|
50
|
+
const { value } = answer;
|
|
51
|
+
return value == null || `${value}`.trim() === '';
|
|
52
|
+
}
|
|
53
|
+
case 'custom-boolean': {
|
|
54
|
+
const answer = findAnswer(getAsArray(appUser, 'customBooleanFieldAnswers'), id);
|
|
55
|
+
// `hasAnswered` is the whole test: a deliberate `false` IS an answer.
|
|
56
|
+
return !answer || !answer.hasAnswered;
|
|
57
|
+
}
|
|
58
|
+
/* istanbul ignore next -- guarded by SUPPORTED_TYPES above */
|
|
59
|
+
default:
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Diffs a user's AppUser record against a site's configured progressive-profile fields.
|
|
66
|
+
*
|
|
67
|
+
* Pure — no i/o, no config access. The middleware calls it to decide whether to gate; the Marko
|
|
68
|
+
* tag calls it (via the state the middleware set) to decide what to render.
|
|
69
|
+
*
|
|
70
|
+
* The NA relaxation in `generate-required-field-payload.js` (skip `regionCode`/`postalCode`
|
|
71
|
+
* outside US/CA/MX) has no analogue here: those are built-ins, which this feature cannot ask for.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} params
|
|
74
|
+
* @param {object} [params.appUser] The full idx AppUser record (with custom answers).
|
|
75
|
+
* @param {import('..').CustomColumnDefinition[][]} [params.fieldRows]
|
|
76
|
+
* @returns {{
|
|
77
|
+
* hasMissing: boolean,
|
|
78
|
+
* missingRows: import('..').CustomColumnDefinition[][],
|
|
79
|
+
* missingCustomFieldIds: string[],
|
|
80
|
+
* missingCount: number,
|
|
81
|
+
* }}
|
|
82
|
+
*/
|
|
83
|
+
module.exports = ({ appUser, fieldRows = [] } = {}) => {
|
|
84
|
+
// Never gate a user we could not read. An absent record is a lookup failure, not a profile with
|
|
85
|
+
// every field empty, and treating it as the latter gates everyone the moment idx has a bad day.
|
|
86
|
+
if (!appUser) {
|
|
87
|
+
return {
|
|
88
|
+
hasMissing: false, missingRows: [], missingCustomFieldIds: [], missingCount: 0,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Rows are preserved so the gate lays out exactly as the site configured it; fully-satisfied
|
|
93
|
+
// rows drop out, partially-satisfied ones keep only their missing columns, and every surviving
|
|
94
|
+
// column is forced required — the gate has no optional questions.
|
|
95
|
+
const missingRows = getAsArray({ fieldRows }, 'fieldRows')
|
|
96
|
+
.map((row) => getAsArray({ row }, 'row')
|
|
97
|
+
.filter((col) => col && isColumnMissing({ appUser, col }))
|
|
98
|
+
.map((col) => ({ ...col, required: true })))
|
|
99
|
+
.filter((row) => row.length);
|
|
100
|
+
|
|
101
|
+
const missingCustomFieldIds = missingRows.reduce((ids, row) => {
|
|
102
|
+
row.forEach((col) => ids.push(col.id));
|
|
103
|
+
return ids;
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
hasMissing: Boolean(missingCustomFieldIds.length),
|
|
108
|
+
missingRows,
|
|
109
|
+
missingCustomFieldIds,
|
|
110
|
+
missingCount: missingCustomFieldIds.length,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
module.exports.SUPPORTED_TYPES = SUPPORTED_TYPES;
|