backend-manager 5.2.15 → 5.2.16
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/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.2.16] - 2026-05-28
|
|
18
|
+
|
|
19
|
+
### Removed
|
|
20
|
+
|
|
21
|
+
- **Dropped the legacy top-level `affiliateCode` field from `/user/signup`.** UJM and all current consumers send the referral code as `attribution.affiliate.code`; the top-level `affiliateCode` (and the normalize-to-`attribution` shim + the `processAffiliate` fallback that read it) was a dead legacy path. Removed from `src/manager/schemas/user/signup/post.js`, the `buildUserRecord` normalize block, and the `processAffiliate` lookup in `src/manager/routes/user/signup/post.js`. The route now reads referral codes exclusively from `attribution.affiliate.code`. (The legacy `bm_api` sign-up action `functions/core/actions/api/user/sign-up.js` is unchanged.)
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Consent: never downgrade an existing granted consent on `/user/signup`** (`src/manager/routes/user/signup/post.js`). A legacy account — signed up before the `flags.signupProcessed` completion flow existed, so the flag was never set — re-fires `/user/signup` on every page load until the flag flips. Its consent payload arrives empty (the original is long gone from `localStorage`), which previously computed `'revoked'` and, on the `{ merge: true }` write, wiped the consent the user actually granted months ago. `buildConsentRecord` now reads the existing doc's consent and preserves any already-`granted` status when the incoming payload doesn't explicitly re-grant it. A genuine new grant still applies; an at-signup decline with no prior grant still records the decline. Added `consent-empty-payload-preserves-existing-grant` and `consent-explicit-decline-does-not-downgrade-existing-grant` tests (+ a dedicated `consent-preserve` test account). See `docs/consent.md`.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **`user/signup` schema: shaped the `consent` field.** `src/manager/schemas/user/signup/post.js` now declares the nested `consent.{legal,marketing}.{granted,text}` shape instead of a bare passthrough object, documenting the input contract at the schema layer (the SSOT for request shape). Each sub-object is optional — omitting it leaves existing consent untouched (see the downgrade guard above).
|
|
30
|
+
|
|
17
31
|
# [5.2.15] - 2026-05-28
|
|
18
32
|
|
|
19
33
|
### Changed
|
package/docs/consent.md
CHANGED
|
@@ -67,16 +67,19 @@ There are four places where consent gets recorded or updated. All four converge
|
|
|
67
67
|
}
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
`buildConsentRecord(assistant, settings.consent)` translates this into the canonical user-doc shape:
|
|
70
|
+
`buildConsentRecord(assistant, settings.consent, creationTime, existingConsent)` translates this into the canonical user-doc shape:
|
|
71
71
|
|
|
72
|
-
- `legal.granted: true` → `legal.status = 'granted'`, `grantedAt` populated with `source: 'signup'` + **
|
|
72
|
+
- `legal.granted: true` → `legal.status = 'granted'`, `grantedAt` populated with `source: 'signup'` + **timestamp from Auth `creationTime`** + server-detected IP + exact label text.
|
|
73
73
|
- `marketing.granted: false` → `marketing.status = 'revoked'`, `grantedAt` all-null, `revokedAt` populated with `source: 'signup'`. (Records the explicit decline.)
|
|
74
|
-
- Missing `consent` block (legacy client) → both default to `'revoked'`.
|
|
75
74
|
|
|
76
|
-
**
|
|
75
|
+
**Timestamps come from Firebase Auth `creationTime`,** not request time, so `consent.grantedAt` matches `metadata.created` (the OMEGA user migration treats `metadata.created` as the SSOT and reconciles `grantedAt` against it — stamping from request time made every new signup drift by a few seconds and get re-fixed on the next migration run).
|
|
76
|
+
|
|
77
|
+
**Server-derived time is authoritative.** Client-supplied timestamps are ignored — defends against clock manipulation by malicious clients.
|
|
77
78
|
|
|
78
79
|
**Strict boolean check.** Only `granted === true` counts as granted. `'true'`, `1`, or other truthy values are rejected.
|
|
79
80
|
|
|
81
|
+
**Never downgrades an existing grant (data-loss guard).** A legacy account — one signed up before the `flags.signupProcessed` completion flow existed, so its flag was never set — re-fires `/user/signup` on every page load until the flag flips. Its consent was captured months ago and is long gone from `localStorage`, so the payload arrives empty. Without protection, `buildConsentRecord` would compute `'revoked'` and the `{ merge: true }` write would wipe the consent the user actually granted. The guard reads the existing doc's consent (`existingConsent`) and **preserves any already-`granted` status when the incoming payload does not explicitly re-grant it.** A genuine new grant still applies; an at-signup decline with no prior grant still records the decline. (The primary mitigation is OMEGA migration Fix 4f, which backfills `flags.signupProcessed: true` for established accounts so they never re-fire; this guard is the backstop for the deploy-before-migration gap.)
|
|
82
|
+
|
|
80
83
|
**Marketing sync gating.** After writing the user doc, the route checks `userRecord.consent.marketing.status === 'granted'` before calling `mailer.sync(uid)`. Declining the marketing checkbox means the user is created normally, gets transactional emails, but is NEVER added to SendGrid / Beehiiv marketing lists.
|
|
81
84
|
|
|
82
85
|
### 2. Account-page toggle (Phase D)
|
package/package.json
CHANGED
|
@@ -61,7 +61,7 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
61
61
|
// 4. Gather all data, then write once
|
|
62
62
|
const email = user.auth.email;
|
|
63
63
|
const inferred = await inferUserContact(assistant, email);
|
|
64
|
-
const userRecord = buildUserRecord(assistant, settings, inferred, authUser.metadata.creationTime);
|
|
64
|
+
const userRecord = buildUserRecord(assistant, settings, inferred, authUser.metadata.creationTime, userDoc);
|
|
65
65
|
|
|
66
66
|
assistant.log(`signup(): Writing user record for ${uid}`, userRecord);
|
|
67
67
|
|
|
@@ -113,15 +113,10 @@ async function pollForUserDoc(assistant, uid) {
|
|
|
113
113
|
/**
|
|
114
114
|
* Build the full user record: client details, attribution, and inferred contact
|
|
115
115
|
*/
|
|
116
|
-
function buildUserRecord(assistant, settings, inferred, creationTime) {
|
|
116
|
+
function buildUserRecord(assistant, settings, inferred, creationTime, existingDoc) {
|
|
117
117
|
const Manager = assistant.Manager;
|
|
118
118
|
const attribution = settings.attribution;
|
|
119
119
|
|
|
120
|
-
// Legacy support: if affiliateCode exists, normalize to new format
|
|
121
|
-
if (settings.affiliateCode && !attribution.affiliate?.code) {
|
|
122
|
-
attribution.affiliate = { code: settings.affiliateCode };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
120
|
const record = {
|
|
126
121
|
flags: {
|
|
127
122
|
signupProcessed: true,
|
|
@@ -138,7 +133,7 @@ function buildUserRecord(assistant, settings, inferred, creationTime) {
|
|
|
138
133
|
},
|
|
139
134
|
},
|
|
140
135
|
attribution: attribution || {},
|
|
141
|
-
consent: buildConsentRecord(assistant, settings.consent, creationTime),
|
|
136
|
+
consent: buildConsentRecord(assistant, settings.consent, creationTime, existingDoc?.consent),
|
|
142
137
|
metadata: Manager.Metadata().set({ tag: 'user/signup' }),
|
|
143
138
|
};
|
|
144
139
|
|
|
@@ -184,7 +179,7 @@ function buildUserRecord(assistant, settings, inferred, creationTime) {
|
|
|
184
179
|
* record what the client sent, but the route will not have reached this point in practice
|
|
185
180
|
* (the signup-form HTML5-requires the legal checkbox).
|
|
186
181
|
*/
|
|
187
|
-
function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
182
|
+
function buildConsentRecord(assistant, clientConsent, creationTime, existingConsent) {
|
|
188
183
|
const consent = clientConsent || {};
|
|
189
184
|
const ip = assistant.request.geolocation?.ip || null;
|
|
190
185
|
|
|
@@ -202,7 +197,7 @@ function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
|
202
197
|
const legalGranted = consent.legal?.granted === true;
|
|
203
198
|
const legalText = typeof consent.legal?.text === 'string' ? consent.legal.text : null;
|
|
204
199
|
|
|
205
|
-
|
|
200
|
+
let legal = legalGranted
|
|
206
201
|
? {
|
|
207
202
|
status: 'granted',
|
|
208
203
|
grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: legalText },
|
|
@@ -216,7 +211,7 @@ function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
|
216
211
|
const marketingGranted = consent.marketing?.granted === true;
|
|
217
212
|
const marketingText = typeof consent.marketing?.text === 'string' ? consent.marketing.text : null;
|
|
218
213
|
|
|
219
|
-
|
|
214
|
+
let marketing = marketingGranted
|
|
220
215
|
? {
|
|
221
216
|
status: 'granted',
|
|
222
217
|
grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: marketingText },
|
|
@@ -229,6 +224,19 @@ function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
|
229
224
|
revokedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: null },
|
|
230
225
|
};
|
|
231
226
|
|
|
227
|
+
// Never DOWNGRADE an existing granted consent. A legacy account (signed up before this
|
|
228
|
+
// flow, flags.signupProcessed never set) re-fires /user/signup on page load with empty
|
|
229
|
+
// consent — which would compute status 'revoked' above and, on a {merge:true} write, wipe
|
|
230
|
+
// out the consent they actually granted months ago. If the existing doc already has a
|
|
231
|
+
// consent granted and the incoming payload doesn't explicitly re-grant it, preserve the
|
|
232
|
+
// existing record. A genuine new grant or an at-signup decline (no prior grant) still applies.
|
|
233
|
+
if (existingConsent?.legal?.status === 'granted' && legal.status !== 'granted') {
|
|
234
|
+
legal = existingConsent.legal;
|
|
235
|
+
}
|
|
236
|
+
if (existingConsent?.marketing?.status === 'granted' && marketing.status !== 'granted') {
|
|
237
|
+
marketing = existingConsent.marketing;
|
|
238
|
+
}
|
|
239
|
+
|
|
232
240
|
assistant.log(`buildConsentRecord: legal=${legal.status}, marketing=${marketing.status} (raw input legal.granted=${consent.legal?.granted}, marketing.granted=${consent.marketing?.granted})`);
|
|
233
241
|
|
|
234
242
|
return { legal, marketing };
|
|
@@ -262,9 +270,7 @@ async function inferUserContact(assistant, email) {
|
|
|
262
270
|
*/
|
|
263
271
|
async function processAffiliate(assistant, uid, email, settings) {
|
|
264
272
|
const { admin } = assistant.Manager.libraries;
|
|
265
|
-
const affiliateCode = settings.attribution?.affiliate?.code
|
|
266
|
-
|| settings.affiliateCode
|
|
267
|
-
|| null;
|
|
273
|
+
const affiliateCode = settings.attribution?.affiliate?.code || null;
|
|
268
274
|
|
|
269
275
|
if (!affiliateCode) {
|
|
270
276
|
return;
|
|
@@ -4,11 +4,6 @@ module.exports = ({ user }) => ({
|
|
|
4
4
|
default: user?.auth?.uid,
|
|
5
5
|
required: false,
|
|
6
6
|
},
|
|
7
|
-
affiliateCode: {
|
|
8
|
-
types: ['string'],
|
|
9
|
-
default: undefined,
|
|
10
|
-
required: false,
|
|
11
|
-
},
|
|
12
7
|
attribution: {
|
|
13
8
|
types: ['object'],
|
|
14
9
|
default: {},
|
|
@@ -19,9 +14,18 @@ module.exports = ({ user }) => ({
|
|
|
19
14
|
default: {},
|
|
20
15
|
required: false,
|
|
21
16
|
},
|
|
17
|
+
// Consent decision captured at signup. Each sub-object is OPTIONAL — if the client omits
|
|
18
|
+
// `legal`/`marketing` (e.g. a legacy account re-firing /user/signup on page load with no
|
|
19
|
+
// fresh consent), the route leaves that consent untouched rather than downgrading it.
|
|
20
|
+
// When present, `granted` is the decision and `text` is the exact copy shown to the user.
|
|
22
21
|
consent: {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
legal: {
|
|
23
|
+
granted: { types: ['boolean'], required: false },
|
|
24
|
+
text: { types: ['string'], required: false },
|
|
25
|
+
},
|
|
26
|
+
marketing: {
|
|
27
|
+
granted: { types: ['boolean'], required: false },
|
|
28
|
+
text: { types: ['string'], required: false },
|
|
29
|
+
},
|
|
26
30
|
},
|
|
27
31
|
});
|
|
@@ -193,6 +193,18 @@ const STATIC_ACCOUNTS = {
|
|
|
193
193
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
194
194
|
},
|
|
195
195
|
},
|
|
196
|
+
// Used to verify the never-downgrade guard: the test seeds this account's doc with already-
|
|
197
|
+
// granted consent, then re-fires /user/signup with an empty consent payload and asserts the
|
|
198
|
+
// grant is preserved (not flipped to revoked). Dedicated account so the seeded state is isolated.
|
|
199
|
+
'consent-preserve': {
|
|
200
|
+
id: 'consent-preserve',
|
|
201
|
+
uid: '_test-consent-preserve',
|
|
202
|
+
email: '_test.consent-preserve@{domain}',
|
|
203
|
+
properties: {
|
|
204
|
+
roles: {},
|
|
205
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
196
208
|
};
|
|
197
209
|
|
|
198
210
|
/**
|
|
@@ -347,6 +347,95 @@ module.exports = {
|
|
|
347
347
|
},
|
|
348
348
|
},
|
|
349
349
|
|
|
350
|
+
// --- Consent downgrade-protection tests ---
|
|
351
|
+
// Guards against data loss when a LEGACY account (signed up before the flags.signupProcessed
|
|
352
|
+
// flow existed, so flag never set) re-fires /user/signup on page load. Its localStorage
|
|
353
|
+
// consent is long gone, so the payload is empty — without the guard, buildConsentRecord
|
|
354
|
+
// would compute 'revoked' and the {merge:true} write would wipe the consent the user
|
|
355
|
+
// actually granted months ago. The guard preserves any existing 'granted' status.
|
|
356
|
+
{
|
|
357
|
+
name: 'consent-empty-payload-preserves-existing-grant',
|
|
358
|
+
async run({ http, firestore, assert, accounts }) {
|
|
359
|
+
const uid = accounts['consent-preserve'].uid;
|
|
360
|
+
|
|
361
|
+
// Seed the doc as an established account whose consent is already granted (as a real
|
|
362
|
+
// legacy signup would be after the OMEGA migration backfilled consent). flags is left
|
|
363
|
+
// at the schema default (signupProcessed: false) to mimic the legacy state exactly.
|
|
364
|
+
// merge:true — preserve the runner-provisioned auth.uid (pollForUserDoc needs it).
|
|
365
|
+
await firestore.set(`users/${uid}`, {
|
|
366
|
+
consent: {
|
|
367
|
+
legal: {
|
|
368
|
+
status: 'granted',
|
|
369
|
+
grantedAt: { timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600, source: 'signup', ip: null, text: 'Legacy legal grant' },
|
|
370
|
+
},
|
|
371
|
+
marketing: {
|
|
372
|
+
status: 'granted',
|
|
373
|
+
grantedAt: { timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600, source: 'signup', ip: null, text: 'Legacy marketing grant' },
|
|
374
|
+
revokedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
flags: { signupProcessed: false },
|
|
378
|
+
}, { merge: true });
|
|
379
|
+
|
|
380
|
+
// Re-fire signup with NO consent payload (the legacy page-load case).
|
|
381
|
+
const signupResponse = await http.as('consent-preserve').post('user/signup', {});
|
|
382
|
+
assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
383
|
+
|
|
384
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
385
|
+
|
|
386
|
+
// CRITICAL: the prior grants must survive — NOT be downgraded to revoked.
|
|
387
|
+
assert.equal(userDoc?.consent?.legal?.status, 'granted', 'legal.status must stay granted (not downgraded by empty payload)');
|
|
388
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.text, 'Legacy legal grant', 'legal grantedAt must be the preserved original, not wiped');
|
|
389
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.timestampUNIX, 1735689600, 'legal grantedAt timestamp must be preserved');
|
|
390
|
+
|
|
391
|
+
assert.equal(userDoc?.consent?.marketing?.status, 'granted', 'marketing.status must stay granted (not downgraded by empty payload)');
|
|
392
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.text, 'Legacy marketing grant', 'marketing grantedAt must be the preserved original');
|
|
393
|
+
// No spurious revokedAt should have been stamped over the preserved grant.
|
|
394
|
+
assert.equal(userDoc?.consent?.marketing?.revokedAt?.timestamp, null, 'marketing revokedAt must stay null (no decline was recorded)');
|
|
395
|
+
|
|
396
|
+
// signupProcessed should now be flipped true by this run.
|
|
397
|
+
assert.equal(userDoc?.flags?.signupProcessed, true, 'signupProcessed should be set true after the run');
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'consent-explicit-decline-does-not-downgrade-existing-grant',
|
|
402
|
+
async run({ http, firestore, assert, accounts }) {
|
|
403
|
+
const uid = accounts['consent-preserve'].uid;
|
|
404
|
+
|
|
405
|
+
// Re-seed: granted marketing + UNSET signupProcessed so the route processes this call.
|
|
406
|
+
// Then send a payload that explicitly DECLINES marketing. The guard must still preserve
|
|
407
|
+
// the existing grant — only an explicit RE-GRANT may overwrite; a decline-over-grant on
|
|
408
|
+
// the signup path is treated as a non-grant and must not wipe a prior consent.
|
|
409
|
+
// merge:true — preserve the runner-provisioned auth.uid (pollForUserDoc needs it).
|
|
410
|
+
await firestore.set(`users/${uid}`, {
|
|
411
|
+
consent: {
|
|
412
|
+
marketing: {
|
|
413
|
+
status: 'granted',
|
|
414
|
+
grantedAt: { timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600, source: 'signup', ip: null, text: 'Prior marketing grant' },
|
|
415
|
+
revokedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
flags: { signupProcessed: false },
|
|
419
|
+
}, { merge: true });
|
|
420
|
+
|
|
421
|
+
const signupResponse = await http.as('consent-preserve').post('user/signup', {
|
|
422
|
+
consent: {
|
|
423
|
+
legal: { granted: true, text: 'Legal grant on re-fire' },
|
|
424
|
+
marketing: { granted: false, text: 'Declining marketing' },
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
428
|
+
|
|
429
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
430
|
+
|
|
431
|
+
// Legal newly granted this call.
|
|
432
|
+
assert.equal(userDoc?.consent?.legal?.status, 'granted', 'legal.status should be granted from this call');
|
|
433
|
+
// Marketing was already granted; an explicit decline must NOT downgrade it.
|
|
434
|
+
assert.equal(userDoc?.consent?.marketing?.status, 'granted', 'marketing.status must stay granted (decline cannot downgrade an existing grant on signup path)');
|
|
435
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.text, 'Prior marketing grant', 'marketing grant must be the preserved original');
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
|
|
350
439
|
// --- Auth rejection test (at end per convention) ---
|
|
351
440
|
{
|
|
352
441
|
name: 'unauthenticated-rejected',
|