@mindful-web/marko-web-identity-x 1.83.1 → 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.
Files changed (35) hide show
  1. package/CLAUDE.md +281 -0
  2. package/api/mutations/progressive-profile-update.js +29 -0
  3. package/browser/index.js +13 -0
  4. package/browser/progressive-profile.vue +388 -0
  5. package/components/access.marko.js +2 -2
  6. package/components/comment-stream.marko.js +2 -2
  7. package/components/context.marko.js +2 -2
  8. package/components/form-access.marko.js +2 -2
  9. package/components/form-authenticate.marko.js +2 -2
  10. package/components/form-change-email.marko.js +2 -2
  11. package/components/form-login.marko.js +2 -2
  12. package/components/form-logout.marko.js +2 -2
  13. package/components/form-profile.marko.js +2 -2
  14. package/components/form-register.marko.js +2 -2
  15. package/components/google-init.marko.js +2 -2
  16. package/components/google-sign-in-button.marko.js +2 -2
  17. package/components/identify.marko.js +2 -2
  18. package/components/marko.json +16 -0
  19. package/components/non-auth-identify.marko +6 -1
  20. package/components/non-auth-identify.marko.js +3 -3
  21. package/components/progressive-profile-form.marko +106 -0
  22. package/components/progressive-profile-form.marko.js +138 -0
  23. package/components/subscribe.marko.js +2 -2
  24. package/config.js +98 -0
  25. package/package.json +3 -2
  26. package/routes/google.js +5 -0
  27. package/routes/index.js +3 -0
  28. package/routes/progressive-profile.js +296 -0
  29. package/service.js +31 -0
  30. package/utils/add-custom-attributes.js +19 -46
  31. package/utils/build-progressive-profile-payload.js +117 -0
  32. package/utils/get-missing-progressive-fields.js +114 -0
  33. package/utils/get-progressive-form-fields.js +67 -0
  34. package/utils/progressive-profile-cookies.js +120 -0
  35. 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
+ };