@mindful-web/marko-web-identity-x 1.83.1 → 1.85.2
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 +302 -0
- package/api/mutations/progressive-profile-update.js +29 -0
- package/browser/index.js +13 -0
- package/browser/progressive-profile.vue +406 -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 +127 -0
- package/components/progressive-profile-form.marko.js +147 -0
- package/components/subscribe.marko.js +2 -2
- package/config.js +98 -0
- package/package.json +3 -2
- 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,67 @@
|
|
|
1
|
+
const { getAsArray } = require('@mindful-web/object-path');
|
|
2
|
+
|
|
3
|
+
const TYPENAME_FOR = {
|
|
4
|
+
'custom-boolean': 'BooleanField',
|
|
5
|
+
'custom-select': 'SelectField',
|
|
6
|
+
'custom-text': 'TextField',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* See `get-missing-progressive-fields.js` — the answer's own `id` is not the field's id.
|
|
11
|
+
*/
|
|
12
|
+
const findAnswer = (answers, fieldId) => answers
|
|
13
|
+
.find((answer) => answer && answer.field && answer.field.id === fieldId);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Builds the `custom*FieldAnswers` props that `browser/custom-column.vue` expects, for a set of
|
|
17
|
+
* progressive-profile field rows.
|
|
18
|
+
*
|
|
19
|
+
* A correct, feature-scoped replacement for `utils/get-form-custom-fields.js`, which is broken in
|
|
20
|
+
* two ways this cannot afford: it matches a user's answer on the answer's own id rather than
|
|
21
|
+
* `answer.field.id` (so it never matches), and then `if (userAnswer)` is truthy on the empty array
|
|
22
|
+
* that produces, so it spreads `undefined` and returns `{}` — an answer object with no `field`,
|
|
23
|
+
* which `custom-column.vue` renders as its raw-metadata `<pre>` debug block. That util is shared
|
|
24
|
+
* with the live survey-access and profile flows across 18 repos, so it is left alone here and
|
|
25
|
+
* fixed separately.
|
|
26
|
+
*
|
|
27
|
+
* **`id` is the field definition's id, not the answer's.** That is the contract
|
|
28
|
+
* `custom-column.vue` reads (`ans.id === fieldId`, where `fieldId` is the configured column id)
|
|
29
|
+
* and what `get-create-user-custom-fields.js` already emits. `build-progressive-profile-payload`
|
|
30
|
+
* reads it back the same way, so the round trip is consistent.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} params
|
|
33
|
+
* @param {object[]} [params.fields] Definitions from `loadActiveContext({ withFields: true })`.
|
|
34
|
+
* @param {import('..').CustomColumnDefinition[][]} [params.fieldRows] Usually `state.missingRows`.
|
|
35
|
+
* @param {object} [params.user] The active/identified AppUser, when known.
|
|
36
|
+
*/
|
|
37
|
+
module.exports = ({ fields = [], fieldRows = [], user } = {}) => {
|
|
38
|
+
const definitions = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
getAsArray({ fieldRows }, 'fieldRows').forEach((row) => getAsArray({ row }, 'row').forEach((col) => {
|
|
41
|
+
if (!col || !col.id || seen.has(col.id)) return;
|
|
42
|
+
const field = fields.find(({ id }) => id === col.id);
|
|
43
|
+
// A configured id with no matching idx field definition cannot be rendered — the Vue column
|
|
44
|
+
// has nothing to draw. Dropping it here leaves an empty column rather than a debug dump.
|
|
45
|
+
if (!field) return;
|
|
46
|
+
seen.add(col.id);
|
|
47
|
+
definitions.push({ col, field });
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const build = (type, emptyAnswer, userAnswerKey) => definitions
|
|
51
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
52
|
+
.filter(({ field }) => field.__typename === TYPENAME_FOR[type])
|
|
53
|
+
.map(({ field }) => {
|
|
54
|
+
const existing = user ? findAnswer(getAsArray(user, userAnswerKey), field.id) : null;
|
|
55
|
+
// Progressive profiling only ever renders *missing* fields, so the answered branch is a
|
|
56
|
+
// safety net rather than the common path. `id` is overwritten with the field id either way.
|
|
57
|
+
return existing
|
|
58
|
+
? { ...existing, id: field.id, field }
|
|
59
|
+
: { id: field.id, field, ...emptyAnswer };
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
customBooleanFieldAnswers: build('custom-boolean', { hasAnswered: false, answer: null, value: null }, 'customBooleanFieldAnswers'),
|
|
64
|
+
customSelectFieldAnswers: build('custom-select', { hasAnswered: false, answers: [] }, 'customSelectFieldAnswers'),
|
|
65
|
+
customTextFieldAnswers: build('custom-text', { hasAnswered: false, value: null }, 'customTextFieldAnswers'),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const { get } = require('@mindful-web/object-path');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The three cookies the progressive-profile gate keeps, in one place so the identity-x route and
|
|
5
|
+
* the theme middleware cannot drift on names or semantics.
|
|
6
|
+
*
|
|
7
|
+
* None is a bot safeguard — that is reCAPTCHA's job. These are the escape valve, the optional
|
|
8
|
+
* dismissal suppression, and the suppression that follows a submission we could not verify.
|
|
9
|
+
*
|
|
10
|
+
* All are `httpOnly: false` to match the rest of this package's cookies (`__idx_idt`,
|
|
11
|
+
* `__idx_form_*`); none authorizes anything, and a client that clears them only earns itself
|
|
12
|
+
* the gate it was trying to escape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ATTEMPTS_COOKIE = '__idx_pp_att';
|
|
16
|
+
const DISMISS_COOKIE = '__idx_pp_dis';
|
|
17
|
+
const UNVERIFIED_COOKIE = '__idx_pp_sup';
|
|
18
|
+
|
|
19
|
+
// Days. Long enough that a false-positive human is not re-asked on their next article, short
|
|
20
|
+
// enough that we get another chance at them within the week.
|
|
21
|
+
const DEFAULT_UNVERIFIED_SUPPRESS_DAYS = 3;
|
|
22
|
+
|
|
23
|
+
// Long enough to outlive a reading session, short enough that a counter left behind by a bad
|
|
24
|
+
// afternoon does not follow someone for a year.
|
|
25
|
+
const ATTEMPTS_MAX_AGE = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const cookieOptions = (maxAge) => ({ maxAge, httpOnly: false, sameSite: 'lax' });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How many times the gate has been rendered to this browser without a successful submit.
|
|
31
|
+
*
|
|
32
|
+
* @param {import('express').Request} req
|
|
33
|
+
* @returns {number}
|
|
34
|
+
*/
|
|
35
|
+
const getAttempts = (req) => {
|
|
36
|
+
const raw = get(req, `cookies.${ATTEMPTS_COOKIE}`);
|
|
37
|
+
const value = Number.parseInt(raw, 10);
|
|
38
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Records one more gate render. The caller releases the content once this passes
|
|
43
|
+
* `maxAttemptsPerUser`.
|
|
44
|
+
*
|
|
45
|
+
* This is the escape valve, not a metric. The known cause of an unclearable gate is gone
|
|
46
|
+
* structurally — there is no `setUnverifiedAppUserData` to no-op silently, and the mutation
|
|
47
|
+
* reports `changed` truthfully — but a gate that can trap a real person is worse than no gate, so
|
|
48
|
+
* the valve stays regardless.
|
|
49
|
+
*/
|
|
50
|
+
const incrementAttempts = (req, res) => {
|
|
51
|
+
const next = getAttempts(req) + 1;
|
|
52
|
+
if (!res.headersSent) res.cookie(ATTEMPTS_COOKIE, `${next}`, cookieOptions(ATTEMPTS_MAX_AGE));
|
|
53
|
+
return next;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const clearAttempts = (res) => {
|
|
57
|
+
if (!res.headersSent) res.clearCookie(ATTEMPTS_COOKIE, { httpOnly: false });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {import('express').Request} req
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
const isDismissed = (req) => Boolean(get(req, `cookies.${DISMISS_COOKIE}`));
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {import('express').Response} res
|
|
68
|
+
* @param {number} days `suppressAfterDismissDays`. Zero or less is a no-op — the default cadence
|
|
69
|
+
* is a hard gate, so dismissal is opt-in per site.
|
|
70
|
+
*/
|
|
71
|
+
const setDismissed = (res, days) => {
|
|
72
|
+
const value = Number(days);
|
|
73
|
+
if (!Number.isFinite(value) || value <= 0) return false;
|
|
74
|
+
if (res.headersSent) return false;
|
|
75
|
+
res.cookie(DISMISS_COOKIE, '1', cookieOptions(value * 24 * 60 * 60 * 1000));
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether this browser recently submitted the gate with a reCAPTCHA result we could not trust.
|
|
81
|
+
*
|
|
82
|
+
* The answers were discarded, so the member's record still has every field missing and the diff
|
|
83
|
+
* would gate them again on the very next article — forever. This cookie is what stops that, and it
|
|
84
|
+
* is not optional: without it the passive-accept path is an infinite loop for anyone reCAPTCHA
|
|
85
|
+
* consistently misjudges.
|
|
86
|
+
*
|
|
87
|
+
* Suppressing the progressive gate hands the visitor back to the content meter, exactly as
|
|
88
|
+
* dismissal does. It is not a free pass to the whole site.
|
|
89
|
+
*
|
|
90
|
+
* @param {import('express').Request} req
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
const isUnverifiedSuppressed = (req) => Boolean(get(req, `cookies.${UNVERIFIED_COOKIE}`));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {import('express').Response} res
|
|
97
|
+
* @param {number} [days] `progressiveProfile.unverifiedSuppressDays`.
|
|
98
|
+
*/
|
|
99
|
+
const setUnverifiedSuppressed = (res, days = DEFAULT_UNVERIFIED_SUPPRESS_DAYS) => {
|
|
100
|
+
const value = Number(days);
|
|
101
|
+
if (!Number.isFinite(value) || value <= 0) return false;
|
|
102
|
+
if (res.headersSent) return false;
|
|
103
|
+
res.cookie(UNVERIFIED_COOKIE, '1', cookieOptions(value * 24 * 60 * 60 * 1000));
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
ATTEMPTS_COOKIE,
|
|
109
|
+
ATTEMPTS_MAX_AGE,
|
|
110
|
+
DEFAULT_UNVERIFIED_SUPPRESS_DAYS,
|
|
111
|
+
DISMISS_COOKIE,
|
|
112
|
+
UNVERIFIED_COOKIE,
|
|
113
|
+
clearAttempts,
|
|
114
|
+
getAttempts,
|
|
115
|
+
incrementAttempts,
|
|
116
|
+
isDismissed,
|
|
117
|
+
isUnverifiedSuppressed,
|
|
118
|
+
setDismissed,
|
|
119
|
+
setUnverifiedSuppressed,
|
|
120
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const { get } = require('@mindful-web/object-path');
|
|
2
|
+
const { newrelicAgent } = require('@mindful-web/utils');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One event per progressive-profile submit, typed by what actually happened.
|
|
6
|
+
*
|
|
7
|
+
* Split by type rather than carried as an `outcome` field so each outcome is independently
|
|
8
|
+
* countable, alertable and dashboardable — "how many passive submits" is a bare `count(*)` on
|
|
9
|
+
* `ProgressiveProfileDiscarded` rather than a filter someone has to remember.
|
|
10
|
+
*
|
|
11
|
+
* Totals and rates span types with a comma-union, which NRQL supports natively:
|
|
12
|
+
*
|
|
13
|
+
* SELECT count(*) FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded,
|
|
14
|
+
* ProgressiveProfileRejected, ProgressiveProfileError FACET eventType() SINCE 1 day ago
|
|
15
|
+
*
|
|
16
|
+
* SELECT percentage(count(*), WHERE eventType() = 'ProgressiveProfileDiscarded')
|
|
17
|
+
* FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago
|
|
18
|
+
*
|
|
19
|
+
* There is deliberately **no `outcome` or `accepted` attribute** — the type carries that fact, and
|
|
20
|
+
* two encodings of the same thing drift. (The HTTP response still returns
|
|
21
|
+
* `accepted: 'written' | 'unverified'`; that is the client contract the dataLayer event reads, and
|
|
22
|
+
* is unrelated.)
|
|
23
|
+
*/
|
|
24
|
+
const EVENT_TYPES = {
|
|
25
|
+
/** The mutation ran. Adds `fieldCount`, `changed`, `profileUpdateHooks`. */
|
|
26
|
+
SUBMIT: 'ProgressiveProfileSubmit',
|
|
27
|
+
/** Passive accept: reCAPTCHA was not satisfied so the write was dropped; `recaptcha` says why. */
|
|
28
|
+
DISCARDED: 'ProgressiveProfileDiscarded',
|
|
29
|
+
/** 401/403/400. Adds `reason`: `no-identity` | `identity-mismatch` | `no-answers`. */
|
|
30
|
+
REJECTED: 'ProgressiveProfileRejected',
|
|
31
|
+
/** Threw (adds `error`), or fell through without recording (adds `reason: 'unrecorded'`). */
|
|
32
|
+
ERROR: 'ProgressiveProfileError',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The New Relic agent for this request.
|
|
37
|
+
*
|
|
38
|
+
* `req.app.locals.newrelic` is the explicit instance a site passed to `startServer` — see
|
|
39
|
+
* mindful-web#314. It wins when present. Otherwise fall back to the shared lazy resolver, which
|
|
40
|
+
* picks up an agent the host app already loaded: the option is new and **no site passes it yet**,
|
|
41
|
+
* so without the fallback this would go dark on every repo until each entrypoint opts in.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('express').Request} req
|
|
44
|
+
*/
|
|
45
|
+
const resolveAgent = (req) => get(req, 'app.locals.newrelic') || newrelicAgent.getAgent();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Records a custom event through this request's agent. The low-level primitive — routes should use
|
|
49
|
+
* `createRecorder` instead, which adds the at-most-once guarantee and attribute accumulation.
|
|
50
|
+
*
|
|
51
|
+
* ## Why a custom event and not transaction attributes
|
|
52
|
+
*
|
|
53
|
+
* **Not because transaction attributes are sampled** — measured, they are not. These apps run
|
|
54
|
+
* 165–588 transactions/minute against a ~10k reservoir, `Transaction` data is queryable 35 days
|
|
55
|
+
* back, and the existing `idxGoogleStep` attributes land and query fine today. Every New Relic
|
|
56
|
+
* destination is reservoir-backed anyway (`transaction_events`, `custom_insights_events`,
|
|
57
|
+
* `application_logging.forwarding`), so there is no unsampled path to move to. Do not "fix" this
|
|
58
|
+
* back to `addCustomAttributes` on the grounds that sampling was never the problem — it wasn't, and
|
|
59
|
+
* these are the reasons:
|
|
60
|
+
*
|
|
61
|
+
* - The rollout decision rule reads `histogram(recaptchaScore)` and `percentile(recaptchaScore)`.
|
|
62
|
+
* A dedicated event type is the right shape for that; `FROM Transaction WHERE name LIKE '%…%'` is
|
|
63
|
+
* a string match against a route path that a refactor can silently break.
|
|
64
|
+
* - It cannot be crowded out by page-view transactions if traffic grows, and it does not depend on
|
|
65
|
+
* `Transaction` retention staying where someone set it.
|
|
66
|
+
*
|
|
67
|
+
* ## Deliberately not sampled
|
|
68
|
+
*
|
|
69
|
+
* Unlike `MindfulApiCall`, which needed a hand-written sampler because it ran ~40M events/month,
|
|
70
|
+
* this is one row per gate submission. There is no `sampleRate` attribute and `sum(sampleRate)`
|
|
71
|
+
* semantics do not apply — `count(*)` is the true count.
|
|
72
|
+
*
|
|
73
|
+
* Never records submitted answer values. Same rule as the p1event `fieldKeys` and the member-event
|
|
74
|
+
* `fieldIds`: which questions were asked, never what was entered.
|
|
75
|
+
*
|
|
76
|
+
* Failure-proof by the same contract as `newrelicAgent` itself — a missing agent is a silent no-op
|
|
77
|
+
* and a throwing one is swallowed. Telemetry must never break a request.
|
|
78
|
+
*
|
|
79
|
+
* @param {import('express').Request} req
|
|
80
|
+
* @param {string} type The event type — one of `EVENT_TYPES`.
|
|
81
|
+
* @param {object} attributes Flat key/values only — New Relic drops nested objects.
|
|
82
|
+
*/
|
|
83
|
+
const recordEvent = (req, type, attributes) => {
|
|
84
|
+
const nr = resolveAgent(req);
|
|
85
|
+
if (!nr || typeof nr.recordCustomEvent !== 'function') return;
|
|
86
|
+
try {
|
|
87
|
+
nr.recordCustomEvent(type, attributes);
|
|
88
|
+
} catch (e) { /* telemetry must never break a request */ }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A per-request recorder that emits **at most one** event, whichever branch gets there first.
|
|
93
|
+
*
|
|
94
|
+
* The route has five terminal branches plus a throw, and each records its own type — which is what
|
|
95
|
+
* makes the outcomes separately queryable. The risk in any such design is arithmetic: a path that
|
|
96
|
+
* records twice inflates a count, and one that records nothing leaves a hole, and either silently
|
|
97
|
+
* skews the rates the rollout decision reads. So the guarantee is enforced here rather than left to
|
|
98
|
+
* whoever edits the route next:
|
|
99
|
+
*
|
|
100
|
+
* - `record` is a no-op once anything has fired, so a double call cannot double count.
|
|
101
|
+
* - The caller pairs it with a `finally` backstop, so a path that recorded nothing still produces a
|
|
102
|
+
* row — and because that row is `ERROR` with `reason: 'unrecorded'`, a branch added without a
|
|
103
|
+
* call site shows up in New Relic instead of quietly going missing.
|
|
104
|
+
*
|
|
105
|
+
* `set` accumulates the attributes shared by whichever type ends up firing — `contentId`, then
|
|
106
|
+
* `recaptcha`/`recaptchaScore` once verified, then `userId`/`auth` once the identity resolves. The
|
|
107
|
+
* alternative is every call site re-assembling the same list, which is precisely how
|
|
108
|
+
* multi-call-site telemetry drifts apart.
|
|
109
|
+
*
|
|
110
|
+
* @param {import('express').Request} req
|
|
111
|
+
*/
|
|
112
|
+
const createRecorder = (req) => {
|
|
113
|
+
const started = Date.now();
|
|
114
|
+
const shared = {};
|
|
115
|
+
let recorded = null;
|
|
116
|
+
return {
|
|
117
|
+
/**
|
|
118
|
+
* Accumulate attributes for whichever event fires.
|
|
119
|
+
* @param {object} attributes
|
|
120
|
+
*/
|
|
121
|
+
set(attributes) {
|
|
122
|
+
Object.assign(shared, attributes);
|
|
123
|
+
return this;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Emit, unless something already did.
|
|
128
|
+
* @param {string} type One of `EVENT_TYPES`.
|
|
129
|
+
* @param {object} [attributes] Merged over the accumulated ones.
|
|
130
|
+
* @returns {?string} The type written, or `null` when suppressed.
|
|
131
|
+
*/
|
|
132
|
+
record(type, attributes = {}) {
|
|
133
|
+
if (recorded) return null;
|
|
134
|
+
recorded = type;
|
|
135
|
+
recordEvent(req, type, { ...shared, ...attributes, ms: Date.now() - started });
|
|
136
|
+
return type;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/** The type already written, if any. */
|
|
140
|
+
get recordedType() {
|
|
141
|
+
return recorded;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
EVENT_TYPES, createRecorder, recordEvent, resolveAgent,
|
|
148
|
+
};
|