backend-manager 5.0.80 → 5.0.82

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.80",
3
+ "version": "5.0.82",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -192,7 +192,7 @@ Module.prototype.defaultize = function () {
192
192
  email.dynamicTemplateData.signoff.urlText = options?.data?.signoff?.urlText || '@ianwieds';
193
193
  }
194
194
 
195
- email.dynamicTemplateData.user = Manager.User(options.user, { defaults: false, prune: false }).properties;
195
+ email.dynamicTemplateData.user = Manager.User(options.user).properties;
196
196
 
197
197
  // Get app configuration from Manager.config.brand (backend-manager-config.json)
198
198
  const brand = Manager.config?.brand;
@@ -13,6 +13,7 @@ Module.prototype.main = function () {
13
13
  timestamp: new Date().toISOString(),
14
14
  environment: assistant.meta?.environment || 'unknown',
15
15
  version: Manager.package?.version || 'unknown',
16
+ bemVersion: Manager.version || 'unknown',
16
17
  };
17
18
 
18
19
  assistant.log('Health check', response);
@@ -1,225 +1,343 @@
1
- const _ = require('lodash');
2
1
  const uuid4 = require('uuid').v4;
3
- const shortid = require('shortid');
4
2
  const powertools = require('node-powertools');
5
3
  const UIDGenerator = require('uid-generator');
6
4
  const uidgen = new UIDGenerator(256);
7
5
 
8
6
  /**
9
- * Helper: returns value if defined, otherwise returns defaultValue (if useDefaults=true) or null
10
- * @param {*} value - The value to check
11
- * @param {*} defaultValue - The default value to use if value is null/undefined
12
- * @param {boolean} useDefaults - Whether to use defaultValue or return null
13
- * @returns {*}
7
+ * User schema definition
8
+ *
9
+ * Each leaf field is { type, default, nullable }
10
+ * Special keys:
11
+ * $passthrough — preserve all existing keys from input, don't strip unknowns
12
+ * $template — shape applied to every dynamic key in a $passthrough object
13
+ * '$template' — (string value) reference to parent's $template
14
+ * '$timestamp' — shorthand for { timestamp, timestampUNIX } defaulting to epoch
15
+ * '$timestamp:now' — same but defaults to current time
16
+ * '$uuid', '$randomId', '$apiKey', '$oldDate' — resolved at runtime
14
17
  */
15
- function getWithDefault(value, defaultValue, useDefaults) {
16
- return value ?? (useDefaults ? defaultValue : null);
17
- }
18
-
19
- function User(Manager, settings, options) {
20
- const self = this;
21
-
22
- self.Manager = Manager;
23
-
24
- settings = settings || {};
25
- options = options || {};
26
-
27
- options.defaults = typeof options.defaults === 'undefined' ? true : options.defaults;
28
- options.prune = typeof options.prune === 'undefined' ? false : options.prune;
29
-
30
- const now = powertools.timestamp(new Date(), {output: 'string'});
31
- const nowUNIX = powertools.timestamp(now, {output: 'unix'});
32
- const oldDate = powertools.timestamp(new Date(0), {output: 'string'})
33
- const oldDateUNIX = powertools.timestamp(oldDate, {output: 'unix'});
34
-
35
- const defaults = options.defaults;
36
-
37
- self.properties = {
38
- auth: {
39
- uid: settings?.auth?.uid ?? null,
40
- email: settings?.auth?.email ?? null,
41
- temporary: getWithDefault(settings?.auth?.temporary, false, defaults),
18
+ const SCHEMA = {
19
+ auth: {
20
+ uid: { type: 'string', default: null, nullable: true },
21
+ email: { type: 'string', default: null, nullable: true },
22
+ temporary: { type: 'boolean', default: false },
23
+ },
24
+ subscription: {
25
+ product: {
26
+ id: { type: 'string', default: 'basic' },
27
+ name: { type: 'string', default: 'Basic' },
42
28
  },
43
- subscription: {
44
- product: {
45
- id: getWithDefault(settings?.subscription?.product?.id, 'basic', defaults), // product ID from config (e.g., 'basic', 'premium', 'pro')
46
- name: getWithDefault(settings?.subscription?.product?.name, 'Basic', defaults), // display name from config
47
- },
48
- status: getWithDefault(settings?.subscription?.status, 'active', defaults), // 'active' | 'suspended' | 'cancelled'
49
- expires: {
50
- timestamp: getWithDefault(settings?.subscription?.expires?.timestamp, oldDate, defaults),
51
- timestampUNIX: getWithDefault(settings?.subscription?.expires?.timestampUNIX, oldDateUNIX, defaults),
52
- },
53
- trial: {
54
- claimed: getWithDefault(settings?.subscription?.trial?.claimed, false, defaults),
55
- expires: {
56
- timestamp: getWithDefault(settings?.subscription?.trial?.expires?.timestamp, oldDate, defaults),
57
- timestampUNIX: getWithDefault(settings?.subscription?.trial?.expires?.timestampUNIX, oldDateUNIX, defaults),
58
- },
59
- },
60
- cancellation: {
61
- pending: getWithDefault(settings?.subscription?.cancellation?.pending, false, defaults),
62
- date: {
63
- timestamp: getWithDefault(settings?.subscription?.cancellation?.date?.timestamp, oldDate, defaults),
64
- timestampUNIX: getWithDefault(settings?.subscription?.cancellation?.date?.timestampUNIX, oldDateUNIX, defaults),
65
- },
66
- },
67
- limits: {
68
- // devices: settings?.subscription?.limits?.devices ?? null,
69
- },
70
- payment: {
71
- processor: settings?.subscription?.payment?.processor ?? null, // 'stripe' | 'paypal' | etc
72
- resourceId: settings?.subscription?.payment?.resourceId ?? null, // subscription ID from provider (e.g., 'sub_xxx')
73
- frequency: settings?.subscription?.payment?.frequency ?? null, // 'monthly' | 'annually'
74
- startDate: {
75
- timestamp: getWithDefault(settings?.subscription?.payment?.startDate?.timestamp, oldDate, defaults),
76
- timestampUNIX: getWithDefault(settings?.subscription?.payment?.startDate?.timestampUNIX, oldDateUNIX, defaults),
77
- },
78
- updatedBy: {
79
- event: {
80
- name: settings?.subscription?.payment?.updatedBy?.event?.name ?? null,
81
- id: settings?.subscription?.payment?.updatedBy?.event?.id ?? null,
82
- },
83
- date: {
84
- timestamp: getWithDefault(settings?.subscription?.payment?.updatedBy?.date?.timestamp, oldDate, defaults),
85
- timestampUNIX: getWithDefault(settings?.subscription?.payment?.updatedBy?.date?.timestampUNIX, oldDateUNIX, defaults),
86
- },
29
+ status: { type: 'string', default: 'active' },
30
+ expires: '$timestamp',
31
+ trial: {
32
+ claimed: { type: 'boolean', default: false },
33
+ expires: '$timestamp',
34
+ },
35
+ cancellation: {
36
+ pending: { type: 'boolean', default: false },
37
+ date: '$timestamp',
38
+ },
39
+ payment: {
40
+ processor: { type: 'string', default: null, nullable: true },
41
+ resourceId: { type: 'string', default: null, nullable: true },
42
+ frequency: { type: 'string', default: null, nullable: true },
43
+ startDate: '$timestamp',
44
+ updatedBy: {
45
+ event: {
46
+ name: { type: 'string', default: null, nullable: true },
47
+ id: { type: 'string', default: null, nullable: true },
87
48
  },
49
+ date: '$timestamp',
88
50
  },
89
51
  },
90
- roles: {
91
- admin: getWithDefault(settings?.roles?.admin, false, defaults),
92
- betaTester: getWithDefault(settings?.roles?.betaTester, false, defaults),
93
- developer: getWithDefault(settings?.roles?.developer, false, defaults),
94
- },
95
- flags: {
96
- signupProcessed: getWithDefault(settings?.flags?.signupProcessed, false, defaults),
52
+ },
53
+ roles: {
54
+ $passthrough: true,
55
+ admin: { type: 'boolean', default: false },
56
+ betaTester: { type: 'boolean', default: false },
57
+ developer: { type: 'boolean', default: false },
58
+ },
59
+ flags: {
60
+ $passthrough: true,
61
+ signupProcessed: { type: 'boolean', default: false },
62
+ },
63
+ affiliate: {
64
+ code: { type: 'string', default: '$randomId' },
65
+ referrals: { type: 'array', default: [] },
66
+ },
67
+ activity: {
68
+ lastActivity: '$timestamp:now',
69
+ created: '$timestamp:now',
70
+ geolocation: {
71
+ ip: { type: 'string', default: null, nullable: true },
72
+ continent: { type: 'string', default: null, nullable: true },
73
+ country: { type: 'string', default: null, nullable: true },
74
+ region: { type: 'string', default: null, nullable: true },
75
+ city: { type: 'string', default: null, nullable: true },
76
+ latitude: { type: 'number', default: 0 },
77
+ longitude: { type: 'number', default: 0 },
97
78
  },
98
- affiliate: {
99
- code: getWithDefault(settings?.affiliate?.code, self.Manager.Utilities().randomId({size: 7}), defaults),
100
- referrals: settings?.affiliate?.referrals ?? [],
79
+ client: {
80
+ language: { type: 'string', default: null, nullable: true },
81
+ mobile: { type: 'boolean', default: false },
82
+ device: { type: 'string', default: null, nullable: true },
83
+ platform: { type: 'string', default: null, nullable: true },
84
+ browser: { type: 'string', default: null, nullable: true },
85
+ vendor: { type: 'string', default: null, nullable: true },
86
+ runtime: { type: 'string', default: null, nullable: true },
87
+ userAgent: { type: 'string', default: null, nullable: true },
88
+ url: { type: 'string', default: null, nullable: true },
101
89
  },
102
- activity: {
103
- lastActivity: {
104
- timestamp: getWithDefault(settings?.activity?.lastActivity?.timestamp, now, defaults),
105
- timestampUNIX: getWithDefault(settings?.activity?.lastActivity?.timestampUNIX, nowUNIX, defaults),
106
- },
107
- created: {
108
- timestamp: getWithDefault(settings?.activity?.created?.timestamp, now, defaults),
109
- timestampUNIX: getWithDefault(settings?.activity?.created?.timestampUNIX, nowUNIX, defaults),
110
- },
111
- geolocation: {
112
- ip: settings?.activity?.geolocation?.ip ?? null,
113
- continent: settings?.activity?.geolocation?.continent ?? null,
114
- country: settings?.activity?.geolocation?.country ?? null,
115
- region: settings?.activity?.geolocation?.region ?? null,
116
- city: settings?.activity?.geolocation?.city ?? null,
117
- latitude: getWithDefault(settings?.activity?.geolocation?.latitude, 0, defaults),
118
- longitude: getWithDefault(settings?.activity?.geolocation?.longitude, 0, defaults),
119
- },
120
- client: {
121
- language: settings?.activity?.client?.language ?? null,
122
- mobile: getWithDefault(settings?.activity?.client?.mobile, false, defaults),
123
- device: settings?.activity?.client?.device ?? null,
124
- platform: settings?.activity?.client?.platform ?? null,
125
- browser: settings?.activity?.client?.browser ?? null,
126
- vendor: settings?.activity?.client?.vendor ?? null,
127
- runtime: settings?.activity?.client?.runtime ?? null,
128
- userAgent: settings?.activity?.client?.userAgent ?? null,
129
- url: settings?.activity?.client?.url ?? null,
90
+ },
91
+ api: {
92
+ clientId: { type: 'string', default: '$uuid' },
93
+ privateKey: { type: 'string', default: '$apiKey' },
94
+ },
95
+ usage: {
96
+ $passthrough: true,
97
+ $template: {
98
+ period: { type: 'number', default: 0 },
99
+ total: { type: 'number', default: 0 },
100
+ last: {
101
+ id: { type: 'string', default: null, nullable: true },
102
+ timestamp: { type: 'string', default: '$oldDate' },
103
+ timestampUNIX: { type: 'number', default: 0 },
130
104
  },
131
105
  },
132
- api: {
133
- clientId: getWithDefault(settings?.api?.clientId, `${uuid4()}`, defaults),
134
- privateKey: getWithDefault(settings?.api?.privateKey, `${uidgen.generateSync()}`, defaults),
106
+ requests: '$template',
107
+ },
108
+ personal: {
109
+ birthday: '$timestamp',
110
+ gender: { type: 'string', default: null, nullable: true },
111
+ location: {
112
+ country: { type: 'string', default: null, nullable: true },
113
+ region: { type: 'string', default: null, nullable: true },
114
+ city: { type: 'string', default: null, nullable: true },
135
115
  },
136
- usage: {
137
- requests: {
138
- period: getWithDefault(settings?.usage?.requests?.period, 0, defaults),
139
- total: getWithDefault(settings?.usage?.requests?.total, 0, defaults),
140
- last: {
141
- id: settings?.usage?.requests?.last?.id ?? null,
142
- timestamp: getWithDefault(settings?.usage?.requests?.last?.timestamp, oldDate, defaults),
143
- timestampUNIX: getWithDefault(settings?.usage?.requests?.last?.timestampUNIX, oldDateUNIX, defaults),
144
- },
145
- },
116
+ name: {
117
+ first: { type: 'string', default: null, nullable: true },
118
+ last: { type: 'string', default: null, nullable: true },
146
119
  },
147
- personal: {
148
- birthday: {
149
- timestamp: getWithDefault(settings?.personal?.birthday?.timestamp, oldDate, defaults),
150
- timestampUNIX: getWithDefault(settings?.personal?.birthday?.timestampUNIX, oldDateUNIX, defaults),
151
- },
152
- gender: settings?.personal?.gender ?? null,
153
- location: {
154
- country: settings?.personal?.location?.country ?? null,
155
- region: settings?.personal?.location?.region ?? null,
156
- city: settings?.personal?.location?.city ?? null,
157
- },
158
- name: {
159
- first: settings?.personal?.name?.first ?? null,
160
- last: settings?.personal?.name?.last ?? null,
161
- },
162
- company: {
163
- name: settings?.personal?.company?.name ?? null,
164
- position: settings?.personal?.company?.position ?? null,
165
- },
166
- telephone: {
167
- countryCode: getWithDefault(settings?.personal?.telephone?.countryCode, 0, defaults),
168
- national: getWithDefault(settings?.personal?.telephone?.national, 0, defaults),
169
- },
120
+ company: {
121
+ name: { type: 'string', default: null, nullable: true },
122
+ position: { type: 'string', default: null, nullable: true },
170
123
  },
171
- oauth2: {
172
- // updated: {
173
- // timestamp: getWithDefault(settings?.oauth2?.updated?.timestamp, oldDate, defaults),
174
- // timestampUNIX: getWithDefault(settings?.oauth2?.updated?.timestampUNIX, oldDateUNIX, defaults),
175
- // },
124
+ telephone: {
125
+ countryCode: { type: 'number', default: 0 },
126
+ national: { type: 'number', default: 0 },
176
127
  },
177
- attribution: {
178
- affiliate: {
179
- code: settings?.attribution?.affiliate?.code ?? null,
180
- timestamp: settings?.attribution?.affiliate?.timestamp ?? null,
181
- url: settings?.attribution?.affiliate?.url ?? null,
182
- page: settings?.attribution?.affiliate?.page ?? null,
183
- },
184
- utm: {
185
- tags: settings?.attribution?.utm?.tags ?? {},
186
- timestamp: settings?.attribution?.utm?.timestamp ?? null,
187
- url: settings?.attribution?.utm?.url ?? null,
188
- page: settings?.attribution?.utm?.page ?? null,
189
- },
128
+ },
129
+ oauth2: {
130
+ $passthrough: true,
131
+ },
132
+ attribution: {
133
+ affiliate: {
134
+ code: { type: 'string', default: null, nullable: true },
135
+ timestamp: { type: 'string', default: null, nullable: true },
136
+ url: { type: 'string', default: null, nullable: true },
137
+ page: { type: 'string', default: null, nullable: true },
190
138
  },
139
+ utm: {
140
+ tags: { $passthrough: true },
141
+ timestamp: { type: 'string', default: null, nullable: true },
142
+ url: { type: 'string', default: null, nullable: true },
143
+ page: { type: 'string', default: null, nullable: true },
144
+ },
145
+ },
146
+ };
147
+
148
+ /**
149
+ * Check if a schema node is a leaf field definition (has 'type' and 'default')
150
+ */
151
+ function isLeaf(node) {
152
+ return node !== null
153
+ && typeof node === 'object'
154
+ && typeof node.type === 'string'
155
+ && 'default' in node;
156
+ }
157
+
158
+ /**
159
+ * Coerce a value to the expected type. Returns the coerced value or undefined if coercion fails.
160
+ */
161
+ function coerce(value, type) {
162
+ if (typeof value === type) {
163
+ return value;
191
164
  }
192
165
 
193
- if (options.prune) {
194
- self.properties = pruneObject(self.properties);
166
+ switch (type) {
167
+ case 'number': {
168
+ const n = Number(value);
169
+ return Number.isNaN(n) ? undefined : n;
170
+ }
171
+ case 'boolean': {
172
+ if (value === 'true' || value === 1) return true;
173
+ if (value === 'false' || value === 0) return false;
174
+ return Boolean(value);
175
+ }
176
+ case 'string': {
177
+ return String(value);
178
+ }
179
+ default: {
180
+ return undefined;
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Resolve a single leaf field value
187
+ */
188
+ function resolveLeaf(leaf, value, ctx) {
189
+ // Null handling
190
+ if (value === null) {
191
+ return leaf.nullable ? null : resolveDefault(leaf.default, ctx);
195
192
  }
196
193
 
197
- self.merge = function (userObject) {
198
- self.properties = _.merge({}, self.properties, userObject)
199
- return self;
194
+ // Undefined apply default
195
+ if (value === undefined) {
196
+ return resolveDefault(leaf.default, ctx);
200
197
  }
201
198
 
202
- return self;
199
+ // Array type — just check it's an array, don't coerce
200
+ if (leaf.type === 'array') {
201
+ return Array.isArray(value) ? value : resolveDefault(leaf.default, ctx);
202
+ }
203
+
204
+ // Type coercion
205
+ if (typeof value !== leaf.type) {
206
+ const coerced = coerce(value, leaf.type);
207
+ return coerced !== undefined ? coerced : resolveDefault(leaf.default, ctx);
208
+ }
209
+
210
+ return value;
211
+ }
212
+
213
+ /**
214
+ * Resolve a default value, handling special tokens
215
+ */
216
+ function resolveDefault(def, ctx) {
217
+ if (typeof def !== 'string' || !def.startsWith('$')) {
218
+ // For arrays, return a fresh copy to avoid shared references
219
+ if (Array.isArray(def)) {
220
+ return [...def];
221
+ }
222
+ return def;
223
+ }
224
+
225
+ switch (def) {
226
+ case '$uuid':
227
+ return `${uuid4()}`;
228
+ case '$randomId':
229
+ return ctx.Manager.Utilities().randomId({ size: 7 });
230
+ case '$apiKey':
231
+ return `${uidgen.generateSync()}`;
232
+ case '$oldDate':
233
+ return ctx.oldDate;
234
+ case '$oldDateUNIX':
235
+ return ctx.oldDateUNIX;
236
+ case '$now':
237
+ return ctx.now;
238
+ case '$nowUNIX':
239
+ return ctx.nowUNIX;
240
+ default:
241
+ return def;
242
+ }
203
243
  }
204
244
 
245
+ /**
246
+ * Expand $timestamp shorthand into a schema branch
247
+ */
248
+ function expandTimestamp(variant) {
249
+ const useNow = variant === '$timestamp:now';
205
250
 
206
- // https://stackoverflow.com/a/26202058/7305269
207
- function pruneObject(obj) {
208
- return function prune(current) {
209
- _.forOwn(current, function (value, key) {
210
- if (_.isUndefined(value) || _.isNull(value) || _.isNaN(value) ||
211
- (_.isObject(value) && _.isEmpty(prune(value)))) {
251
+ return {
252
+ timestamp: { type: 'string', default: useNow ? '$now' : '$oldDate' },
253
+ timestampUNIX: { type: 'number', default: useNow ? '$nowUNIX' : 0 },
254
+ };
255
+ }
212
256
 
213
- delete current[key];
257
+ /**
258
+ * Recursively resolve a schema node against input data
259
+ */
260
+ function resolve(schema, data, ctx) {
261
+ data = data || {};
262
+ const result = {};
263
+
264
+ // If $passthrough, start by copying all existing keys from data
265
+ const isPassthrough = schema.$passthrough === true;
266
+ const template = schema.$template || null;
267
+
268
+ if (isPassthrough) {
269
+ // Copy all data keys first (they'll be overwritten by defined schema fields below)
270
+ for (const key of Object.keys(data)) {
271
+ if (template && !key.startsWith('$') && !(key in schema)) {
272
+ // Dynamic key — resolve against template
273
+ result[key] = resolve(template, data[key], ctx);
274
+ } else if (!(key in schema) || key.startsWith('$')) {
275
+ // Unknown key not in schema — passthrough as-is
276
+ result[key] = data[key];
214
277
  }
215
- });
216
- // remove any leftover undefined values from the delete
217
- // operation on an array
218
- if (_.isArray(current)) _.pull(current, undefined);
278
+ }
279
+ }
219
280
 
220
- return current;
281
+ // Now resolve each defined schema field
282
+ for (const [key, node] of Object.entries(schema)) {
283
+ // Skip meta keys
284
+ if (key.startsWith('$')) {
285
+ continue;
286
+ }
287
+
288
+ // Handle string shorthands
289
+ if (typeof node === 'string') {
290
+ if (node === '$template') {
291
+ // Resolve against parent's $template
292
+ result[key] = resolve(template, data[key], ctx);
293
+ continue;
294
+ }
295
+ if (node.startsWith('$timestamp')) {
296
+ // Expand timestamp shorthand and recurse
297
+ result[key] = resolve(expandTimestamp(node), data[key], ctx);
298
+ continue;
299
+ }
300
+ }
301
+
302
+ // Leaf field
303
+ if (isLeaf(node)) {
304
+ result[key] = resolveLeaf(node, data[key], ctx);
305
+ continue;
306
+ }
307
+
308
+ // Nested branch (plain object)
309
+ if (node !== null && typeof node === 'object') {
310
+ result[key] = resolve(node, data[key], ctx);
311
+ continue;
312
+ }
313
+ }
314
+
315
+ return result;
316
+ }
221
317
 
222
- }(_.cloneDeep(obj)); // Do not modify the original object, create a clone instead
318
+ // ─── User constructor ───
319
+
320
+ function User(Manager, settings) {
321
+ const self = this;
322
+
323
+ self.Manager = Manager;
324
+
325
+ settings = settings || {};
326
+
327
+ // Build resolver context
328
+ const now = powertools.timestamp(new Date(), { output: 'string' });
329
+ const ctx = {
330
+ Manager,
331
+ now: now,
332
+ nowUNIX: powertools.timestamp(now, { output: 'unix' }),
333
+ oldDate: powertools.timestamp(new Date(0), { output: 'string' }),
334
+ oldDateUNIX: powertools.timestamp(new Date(0), { output: 'unix' }),
335
+ };
336
+
337
+ // Resolve
338
+ self.properties = resolve(SCHEMA, settings, ctx);
339
+
340
+ return self;
223
341
  }
224
342
 
225
343
  module.exports = User;
@@ -18,10 +18,14 @@ const cron = path.resolve(__dirname, './cron');
18
18
  const events = path.resolve(__dirname, './events');
19
19
 
20
20
  const BEM_CONFIG_TEMPLATE_PATH = path.resolve(__dirname, '../../templates/backend-manager-config.json');
21
+ const BEM_PACKAGE = require('../../package.json');
21
22
 
22
23
  function Manager() {
23
24
  const self = this;
24
25
 
26
+ // BEM library version
27
+ self.version = BEM_PACKAGE.version;
28
+
25
29
  // Constants
26
30
  self.SERVER_UUID = '11111111-1111-1111-1111-111111111111';
27
31
 
@@ -171,7 +171,7 @@ async function buildEmail(Manager, assistant, settings) {
171
171
  email.dynamicTemplateData.signoff.urlText = settings?.data?.signoff?.urlText || '@ianwieds';
172
172
  }
173
173
 
174
- email.dynamicTemplateData.user = Manager.User(settings.user, { defaults: false, prune: false }).properties;
174
+ email.dynamicTemplateData.user = Manager.User(settings.user).properties;
175
175
 
176
176
  // Get app configuration from Manager.config.brand
177
177
  const brand = Manager.config?.brand;
@@ -5,6 +5,7 @@ module.exports = async ({ assistant, Manager }) => {
5
5
  timestamp: new Date().toISOString(),
6
6
  environment: assistant.meta?.environment || 'unknown',
7
7
  version: Manager.package?.version || 'unknown',
8
+ bemVersion: Manager.version || 'unknown',
8
9
  };
9
10
 
10
11
  assistant.log('Health check', response);
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Test: helpers/user
3
+ * Unit tests for Manager.User() schema-driven normalization
4
+ *
5
+ * Tests the declarative schema resolver: defaults, passthrough, templates, type coercion
6
+ */
7
+ const User = require('../../src/manager/helpers/user.js');
8
+
9
+ // Mock Manager with minimal Utilities
10
+ const Manager = {
11
+ Utilities: () => ({
12
+ randomId: ({ size }) => 'test123',
13
+ }),
14
+ };
15
+
16
+ function createUser(settings) {
17
+ return new User(Manager, settings).properties;
18
+ }
19
+
20
+ module.exports = {
21
+ description: 'User() schema resolver',
22
+ type: 'group',
23
+
24
+ tests: [
25
+ // ─── Empty / new user ───
26
+
27
+ {
28
+ name: 'empty-settings-gets-all-defaults',
29
+ async run({ assert }) {
30
+ const user = createUser({});
31
+
32
+ // Auth
33
+ assert.equal(user.auth.uid, null, 'auth.uid should be null');
34
+ assert.equal(user.auth.email, null, 'auth.email should be null');
35
+ assert.equal(user.auth.temporary, false, 'auth.temporary should be false');
36
+
37
+ // Subscription
38
+ assert.equal(user.subscription.product.id, 'basic', 'subscription.product.id should be basic');
39
+ assert.equal(user.subscription.product.name, 'Basic', 'subscription.product.name should be Basic');
40
+ assert.equal(user.subscription.status, 'active', 'subscription.status should be active');
41
+ assert.equal(user.subscription.trial.claimed, false, 'subscription.trial.claimed should be false');
42
+ assert.equal(user.subscription.cancellation.pending, false, 'subscription.cancellation.pending should be false');
43
+
44
+ // Timestamps should exist
45
+ assert.ok(user.subscription.expires.timestamp, 'subscription.expires.timestamp should exist');
46
+ assert.equal(typeof user.subscription.expires.timestampUNIX, 'number', 'subscription.expires.timestampUNIX should be number');
47
+
48
+ // Roles
49
+ assert.equal(user.roles.admin, false, 'roles.admin should be false');
50
+ assert.equal(user.roles.betaTester, false, 'roles.betaTester should be false');
51
+ assert.equal(user.roles.developer, false, 'roles.developer should be false');
52
+
53
+ // Flags
54
+ assert.equal(user.flags.signupProcessed, false, 'flags.signupProcessed should be false');
55
+
56
+ // Affiliate
57
+ assert.equal(typeof user.affiliate.code, 'string', 'affiliate.code should be string');
58
+ assert.ok(user.affiliate.code.length > 0, 'affiliate.code should not be empty');
59
+ assert.ok(Array.isArray(user.affiliate.referrals), 'affiliate.referrals should be array');
60
+
61
+ // Activity
62
+ assert.ok(user.activity.lastActivity.timestamp, 'activity.lastActivity.timestamp should exist');
63
+ assert.ok(user.activity.created.timestamp, 'activity.created.timestamp should exist');
64
+ assert.equal(user.activity.geolocation.latitude, 0, 'geolocation.latitude should be 0');
65
+ assert.equal(user.activity.geolocation.longitude, 0, 'geolocation.longitude should be 0');
66
+ assert.equal(user.activity.client.mobile, false, 'client.mobile should be false');
67
+
68
+ // API keys
69
+ assert.equal(typeof user.api.clientId, 'string', 'api.clientId should be string');
70
+ assert.ok(user.api.clientId.length > 0, 'api.clientId should not be empty');
71
+ assert.equal(typeof user.api.privateKey, 'string', 'api.privateKey should be string');
72
+ assert.ok(user.api.privateKey.length > 0, 'api.privateKey should not be empty');
73
+
74
+ // Usage
75
+ assert.equal(user.usage.requests.period, 0, 'usage.requests.period should be 0');
76
+ assert.equal(user.usage.requests.total, 0, 'usage.requests.total should be 0');
77
+ assert.equal(user.usage.requests.last.id, null, 'usage.requests.last.id should be null');
78
+
79
+ // Personal
80
+ assert.equal(user.personal.name.first, null, 'personal.name.first should be null');
81
+ assert.equal(user.personal.name.last, null, 'personal.name.last should be null');
82
+ assert.equal(user.personal.telephone.countryCode, 0, 'telephone.countryCode should be 0');
83
+
84
+ // OAuth2
85
+ assert.deepEqual(user.oauth2, {}, 'oauth2 should be empty object');
86
+
87
+ // Attribution
88
+ assert.equal(user.attribution.affiliate.code, null, 'attribution.affiliate.code should be null');
89
+ assert.deepEqual(user.attribution.utm.tags, {}, 'attribution.utm.tags should be empty object');
90
+ },
91
+ },
92
+
93
+ {
94
+ name: 'undefined-settings-gets-all-defaults',
95
+ async run({ assert }) {
96
+ const user = new User(Manager).properties;
97
+
98
+ assert.equal(user.auth.uid, null, 'auth.uid should be null');
99
+ assert.equal(user.subscription.product.id, 'basic', 'subscription.product.id should be basic');
100
+ assert.equal(user.roles.admin, false, 'roles.admin should be false');
101
+ },
102
+ },
103
+
104
+ // ─── Preserving real user data ───
105
+
106
+ {
107
+ name: 'real-data-takes-precedence-over-defaults',
108
+ async run({ assert }) {
109
+ const user = createUser({
110
+ auth: { uid: 'user123', email: 'test@test.com', temporary: true },
111
+ subscription: { product: { id: 'pro', name: 'Pro' }, status: 'cancelled' },
112
+ roles: { admin: true, betaTester: true, developer: false },
113
+ personal: { name: { first: 'Ian', last: 'W' } },
114
+ });
115
+
116
+ assert.equal(user.auth.uid, 'user123', 'auth.uid should be preserved');
117
+ assert.equal(user.auth.email, 'test@test.com', 'auth.email should be preserved');
118
+ assert.equal(user.auth.temporary, true, 'auth.temporary should be preserved');
119
+ assert.equal(user.subscription.product.id, 'pro', 'subscription.product.id should be preserved');
120
+ assert.equal(user.subscription.status, 'cancelled', 'subscription.status should be preserved');
121
+ assert.equal(user.roles.admin, true, 'roles.admin should be preserved');
122
+ assert.equal(user.personal.name.first, 'Ian', 'personal.name.first should be preserved');
123
+ },
124
+ },
125
+
126
+ // ─── $passthrough: oauth2 ───
127
+
128
+ {
129
+ name: 'oauth2-passthrough-preserves-provider-data',
130
+ async run({ assert }) {
131
+ const googleToken = {
132
+ access_token: 'ya29.xxx',
133
+ refresh_token: '1//xxx',
134
+ expiry_date: 1700000000,
135
+ };
136
+ const msToken = {
137
+ access_token: 'eyJ.xxx',
138
+ refresh_token: 'M.xxx',
139
+ };
140
+
141
+ const user = createUser({
142
+ oauth2: {
143
+ google: {
144
+ token: googleToken,
145
+ identity: { email: 'g@gmail.com', name: 'Test' },
146
+ },
147
+ microsoft: {
148
+ token: msToken,
149
+ },
150
+ },
151
+ });
152
+
153
+ assert.equal(user.oauth2.google.token.access_token, 'ya29.xxx', 'google access_token preserved');
154
+ assert.equal(user.oauth2.google.token.refresh_token, '1//xxx', 'google refresh_token preserved');
155
+ assert.equal(user.oauth2.google.identity.email, 'g@gmail.com', 'google identity preserved');
156
+ assert.equal(user.oauth2.microsoft.token.access_token, 'eyJ.xxx', 'microsoft token preserved');
157
+ },
158
+ },
159
+
160
+ {
161
+ name: 'oauth2-empty-when-not-provided',
162
+ async run({ assert }) {
163
+ const user = createUser({});
164
+ assert.deepEqual(user.oauth2, {}, 'oauth2 should be empty object when not provided');
165
+ },
166
+ },
167
+
168
+ // ─── $passthrough: roles (with defined defaults) ───
169
+
170
+ {
171
+ name: 'roles-passthrough-preserves-custom-roles',
172
+ async run({ assert }) {
173
+ const user = createUser({
174
+ roles: { admin: true, customRole: true, moderator: false },
175
+ });
176
+
177
+ assert.equal(user.roles.admin, true, 'admin should be true');
178
+ assert.equal(user.roles.customRole, true, 'customRole should be preserved');
179
+ assert.equal(user.roles.moderator, false, 'moderator should be preserved');
180
+ assert.equal(user.roles.betaTester, false, 'betaTester should get default');
181
+ assert.equal(user.roles.developer, false, 'developer should get default');
182
+ },
183
+ },
184
+
185
+ // ─── $passthrough: flags (with defined defaults) ───
186
+
187
+ {
188
+ name: 'flags-passthrough-preserves-custom-flags',
189
+ async run({ assert }) {
190
+ const user = createUser({
191
+ flags: { signupProcessed: true, featureX: true, betaOptIn: false },
192
+ });
193
+
194
+ assert.equal(user.flags.signupProcessed, true, 'signupProcessed preserved');
195
+ assert.equal(user.flags.featureX, true, 'featureX preserved');
196
+ assert.equal(user.flags.betaOptIn, false, 'betaOptIn preserved');
197
+ },
198
+ },
199
+
200
+ // ─── $template: usage with dynamic keys ───
201
+
202
+ {
203
+ name: 'usage-template-preserves-dynamic-keys',
204
+ async run({ assert }) {
205
+ const user = createUser({
206
+ usage: {
207
+ requests: { period: 10, total: 100, last: { id: 'r1', timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600 } },
208
+ emails: { period: 5, total: 50, last: { id: 'e1', timestamp: '2025-01-02T00:00:00.000Z', timestampUNIX: 1735776000 } },
209
+ sends: { period: 3, total: 30 },
210
+ },
211
+ });
212
+
213
+ // Defined key (requests)
214
+ assert.equal(user.usage.requests.period, 10, 'requests.period preserved');
215
+ assert.equal(user.usage.requests.total, 100, 'requests.total preserved');
216
+ assert.equal(user.usage.requests.last.id, 'r1', 'requests.last.id preserved');
217
+
218
+ // Dynamic key (emails) — full data
219
+ assert.equal(user.usage.emails.period, 5, 'emails.period preserved');
220
+ assert.equal(user.usage.emails.total, 50, 'emails.total preserved');
221
+ assert.equal(user.usage.emails.last.id, 'e1', 'emails.last.id preserved');
222
+
223
+ // Dynamic key (sends) — partial data, template fills in missing
224
+ assert.equal(user.usage.sends.period, 3, 'sends.period preserved');
225
+ assert.equal(user.usage.sends.total, 30, 'sends.total preserved');
226
+ assert.equal(user.usage.sends.last.id, null, 'sends.last.id defaulted to null');
227
+ assert.ok(user.usage.sends.last.timestamp, 'sends.last.timestamp defaulted');
228
+ assert.equal(typeof user.usage.sends.last.timestampUNIX, 'number', 'sends.last.timestampUNIX defaulted to number');
229
+ },
230
+ },
231
+
232
+ {
233
+ name: 'usage-only-requests-when-no-extra-keys',
234
+ async run({ assert }) {
235
+ const user = createUser({});
236
+
237
+ assert.ok(user.usage.requests, 'usage.requests should exist');
238
+ assert.equal(Object.keys(user.usage).length, 1, 'usage should only have requests key');
239
+ },
240
+ },
241
+
242
+ // ─── $passthrough: utm.tags ───
243
+
244
+ {
245
+ name: 'utm-tags-passthrough-preserves-all-tags',
246
+ async run({ assert }) {
247
+ const user = createUser({
248
+ attribution: {
249
+ utm: {
250
+ tags: { source: 'google', medium: 'cpc', campaign: 'summer' },
251
+ timestamp: '2025-06-01T00:00:00.000Z',
252
+ },
253
+ },
254
+ });
255
+
256
+ assert.equal(user.attribution.utm.tags.source, 'google', 'utm source preserved');
257
+ assert.equal(user.attribution.utm.tags.medium, 'cpc', 'utm medium preserved');
258
+ assert.equal(user.attribution.utm.tags.campaign, 'summer', 'utm campaign preserved');
259
+ assert.equal(user.attribution.utm.timestamp, '2025-06-01T00:00:00.000Z', 'utm timestamp preserved');
260
+ },
261
+ },
262
+
263
+ // ─── Type coercion ───
264
+
265
+ {
266
+ name: 'coerces-number-from-string',
267
+ async run({ assert }) {
268
+ const user = createUser({
269
+ activity: { geolocation: { latitude: '42.5', longitude: '-73.2' } },
270
+ personal: { telephone: { countryCode: '44', national: '7911123456' } },
271
+ });
272
+
273
+ assert.equal(user.activity.geolocation.latitude, 42.5, 'string "42.5" coerced to number');
274
+ assert.equal(user.activity.geolocation.longitude, -73.2, 'string "-73.2" coerced to number');
275
+ assert.equal(user.personal.telephone.countryCode, 44, 'string "44" coerced to number');
276
+ },
277
+ },
278
+
279
+ {
280
+ name: 'coerces-boolean-from-number',
281
+ async run({ assert }) {
282
+ const user = createUser({
283
+ auth: { temporary: 1 },
284
+ activity: { client: { mobile: 0 } },
285
+ });
286
+
287
+ assert.equal(user.auth.temporary, true, '1 coerced to true');
288
+ assert.equal(user.activity.client.mobile, false, '0 coerced to false');
289
+ },
290
+ },
291
+
292
+ {
293
+ name: 'coerces-boolean-from-string',
294
+ async run({ assert }) {
295
+ const user = createUser({
296
+ auth: { temporary: 'true' },
297
+ roles: { admin: 'false' },
298
+ });
299
+
300
+ assert.equal(user.auth.temporary, true, '"true" coerced to true');
301
+ assert.equal(user.roles.admin, false, '"false" coerced to false');
302
+ },
303
+ },
304
+
305
+ {
306
+ name: 'invalid-coercion-falls-back-to-default',
307
+ async run({ assert }) {
308
+ const user = createUser({
309
+ activity: { geolocation: { latitude: 'not-a-number', longitude: undefined } },
310
+ personal: { telephone: { national: 'abc' } },
311
+ });
312
+
313
+ assert.equal(user.activity.geolocation.latitude, 0, 'invalid string falls back to default 0');
314
+ assert.equal(user.activity.geolocation.longitude, 0, 'undefined falls back to default 0');
315
+ assert.equal(user.personal.telephone.national, 0, 'non-numeric string falls back to default 0');
316
+ },
317
+ },
318
+
319
+ // ─── Nullable fields ───
320
+
321
+ {
322
+ name: 'nullable-fields-preserve-null',
323
+ async run({ assert }) {
324
+ const user = createUser({
325
+ auth: { uid: null, email: null },
326
+ personal: { name: { first: null, last: null }, gender: null },
327
+ activity: { geolocation: { ip: null, country: null } },
328
+ });
329
+
330
+ assert.equal(user.auth.uid, null, 'null uid preserved');
331
+ assert.equal(user.auth.email, null, 'null email preserved');
332
+ assert.equal(user.personal.name.first, null, 'null first name preserved');
333
+ assert.equal(user.personal.gender, null, 'null gender preserved');
334
+ assert.equal(user.activity.geolocation.ip, null, 'null ip preserved');
335
+ },
336
+ },
337
+
338
+ {
339
+ name: 'non-nullable-fields-replace-null-with-default',
340
+ async run({ assert }) {
341
+ const user = createUser({
342
+ auth: { temporary: null },
343
+ roles: { admin: null },
344
+ activity: { geolocation: { latitude: null } },
345
+ });
346
+
347
+ // These are non-nullable, so null should be replaced with the schema default
348
+ assert.equal(user.auth.temporary, false, 'null temporary replaced with false');
349
+ assert.equal(user.roles.admin, false, 'null admin replaced with false');
350
+ assert.equal(user.activity.geolocation.latitude, 0, 'null latitude replaced with 0');
351
+ },
352
+ },
353
+
354
+ // ─── Fragmented / incomplete users ───
355
+
356
+ {
357
+ name: 'partial-subscription-fills-missing-fields',
358
+ async run({ assert }) {
359
+ const user = createUser({
360
+ subscription: { product: { id: 'premium' } },
361
+ });
362
+
363
+ assert.equal(user.subscription.product.id, 'premium', 'provided id preserved');
364
+ assert.equal(user.subscription.product.name, 'Basic', 'missing name gets default');
365
+ assert.equal(user.subscription.status, 'active', 'missing status gets default');
366
+ assert.equal(user.subscription.trial.claimed, false, 'missing trial.claimed gets default');
367
+ assert.ok(user.subscription.expires.timestamp, 'missing expires gets default timestamp');
368
+ },
369
+ },
370
+
371
+ {
372
+ name: 'partial-activity-fills-missing-fields',
373
+ async run({ assert }) {
374
+ const user = createUser({
375
+ activity: { geolocation: { country: 'US' } },
376
+ });
377
+
378
+ assert.equal(user.activity.geolocation.country, 'US', 'provided country preserved');
379
+ assert.equal(user.activity.geolocation.ip, null, 'missing ip defaults to null');
380
+ assert.equal(user.activity.geolocation.latitude, 0, 'missing latitude defaults to 0');
381
+ assert.ok(user.activity.lastActivity.timestamp, 'missing lastActivity gets default');
382
+ assert.ok(user.activity.created.timestamp, 'missing created gets default');
383
+ },
384
+ },
385
+
386
+ {
387
+ name: 'deeply-nested-partial-payment-fills-correctly',
388
+ async run({ assert }) {
389
+ const user = createUser({
390
+ subscription: {
391
+ payment: { processor: 'stripe', resourceId: 'sub_123' },
392
+ },
393
+ });
394
+
395
+ assert.equal(user.subscription.payment.processor, 'stripe', 'processor preserved');
396
+ assert.equal(user.subscription.payment.resourceId, 'sub_123', 'resourceId preserved');
397
+ assert.equal(user.subscription.payment.frequency, null, 'missing frequency defaults to null');
398
+ assert.ok(user.subscription.payment.startDate.timestamp, 'missing startDate gets default');
399
+ assert.equal(user.subscription.payment.updatedBy.event.name, null, 'missing event.name defaults to null');
400
+ },
401
+ },
402
+
403
+ // ─── Top-level structure ───
404
+
405
+ {
406
+ name: 'all-top-level-keys-present',
407
+ async run({ assert }) {
408
+ const user = createUser({});
409
+ const expectedKeys = [
410
+ 'auth', 'subscription', 'roles', 'flags', 'affiliate',
411
+ 'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution',
412
+ ];
413
+
414
+ for (const key of expectedKeys) {
415
+ assert.ok(key in user, `Top-level key "${key}" should exist`);
416
+ }
417
+ },
418
+ },
419
+
420
+ {
421
+ name: 'no-extra-top-level-keys',
422
+ async run({ assert }) {
423
+ const user = createUser({});
424
+ const expectedKeys = [
425
+ 'auth', 'subscription', 'roles', 'flags', 'affiliate',
426
+ 'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution',
427
+ ];
428
+
429
+ for (const key of Object.keys(user)) {
430
+ assert.ok(expectedKeys.includes(key), `Unexpected top-level key "${key}"`);
431
+ }
432
+ },
433
+ },
434
+
435
+ // ─── Unique/generated values ───
436
+
437
+ {
438
+ name: 'api-keys-are-unique-per-call',
439
+ async run({ assert }) {
440
+ const u1 = createUser({});
441
+ const u2 = createUser({});
442
+
443
+ // privateKey uses UIDGenerator which should produce unique values
444
+ assert.notEqual(u1.api.privateKey, u2.api.privateKey, 'privateKey should be unique per call');
445
+ assert.notEqual(u1.api.clientId, u2.api.clientId, 'clientId should be unique per call');
446
+ },
447
+ },
448
+
449
+ {
450
+ name: 'existing-api-keys-not-overwritten',
451
+ async run({ assert }) {
452
+ const user = createUser({
453
+ api: { clientId: 'my-client-id', privateKey: 'my-secret-key' },
454
+ });
455
+
456
+ assert.equal(user.api.clientId, 'my-client-id', 'existing clientId preserved');
457
+ assert.equal(user.api.privateKey, 'my-secret-key', 'existing privateKey preserved');
458
+ },
459
+ },
460
+
461
+ // ─── Complex real-world user ───
462
+
463
+ {
464
+ name: 'full-real-world-user-preserves-everything',
465
+ async run({ assert }) {
466
+ const user = createUser({
467
+ auth: { uid: 'V4U9wR0AiLUQRxpcP7WhgA4FX9H2', email: 'ian@example.com', temporary: false },
468
+ subscription: {
469
+ product: { id: 'premium', name: 'Premium' },
470
+ status: 'active',
471
+ expires: { timestamp: '2026-12-31T00:00:00.000Z', timestampUNIX: 1798761600 },
472
+ trial: { claimed: true, expires: { timestamp: '2024-01-01T00:00:00.000Z', timestampUNIX: 1704067200 } },
473
+ payment: { processor: 'stripe', resourceId: 'sub_abc', frequency: 'annually' },
474
+ },
475
+ roles: { admin: true, betaTester: true, developer: true, superAdmin: true },
476
+ flags: { signupProcessed: true, onboarded: true },
477
+ affiliate: { code: 'IAN7', referrals: ['ref1', 'ref2'] },
478
+ api: { clientId: 'uuid-123', privateKey: 'key-456' },
479
+ oauth2: {
480
+ google: {
481
+ token: { access_token: 'ya29.real', refresh_token: '1//real', expiry_date: 1700000000 },
482
+ identity: { email: 'ian@gmail.com', name: 'Ian W', picture: 'https://photo.url' },
483
+ },
484
+ },
485
+ usage: {
486
+ requests: { period: 100, total: 5000, last: { id: 'req-z', timestamp: '2025-12-01T00:00:00.000Z', timestampUNIX: 1764633600 } },
487
+ emails: { period: 42, total: 2100, last: { id: 'em-z', timestamp: '2025-12-01T00:00:00.000Z', timestampUNIX: 1764633600 } },
488
+ },
489
+ personal: {
490
+ name: { first: 'Ian', last: 'Wiedenman' },
491
+ company: { name: 'ITW Creative Works', position: 'CEO' },
492
+ birthday: { timestamp: '1990-05-15T00:00:00.000Z', timestampUNIX: 642988800 },
493
+ },
494
+ attribution: {
495
+ affiliate: { code: 'PARTNER1', timestamp: '2024-06-01T00:00:00.000Z' },
496
+ utm: { tags: { source: 'twitter', campaign: 'launch' }, url: 'https://example.com' },
497
+ },
498
+ });
499
+
500
+ // Everything should be preserved exactly
501
+ assert.equal(user.auth.uid, 'V4U9wR0AiLUQRxpcP7WhgA4FX9H2', 'uid preserved');
502
+ assert.equal(user.subscription.product.id, 'premium', 'product preserved');
503
+ assert.equal(user.roles.superAdmin, true, 'custom role preserved');
504
+ assert.equal(user.flags.onboarded, true, 'custom flag preserved');
505
+ assert.equal(user.affiliate.code, 'IAN7', 'affiliate code preserved');
506
+ assert.deepEqual(user.affiliate.referrals, ['ref1', 'ref2'], 'referrals preserved');
507
+ assert.equal(user.api.clientId, 'uuid-123', 'api clientId preserved');
508
+ assert.equal(user.oauth2.google.token.access_token, 'ya29.real', 'oauth2 token preserved');
509
+ assert.equal(user.oauth2.google.identity.email, 'ian@gmail.com', 'oauth2 identity preserved');
510
+ assert.equal(user.usage.emails.period, 42, 'usage emails preserved');
511
+ assert.equal(user.personal.name.first, 'Ian', 'name preserved');
512
+ assert.equal(user.personal.company.name, 'ITW Creative Works', 'company preserved');
513
+ assert.equal(user.personal.birthday.timestampUNIX, 642988800, 'birthday preserved');
514
+ assert.equal(user.attribution.utm.tags.source, 'twitter', 'utm tags preserved');
515
+ },
516
+ },
517
+ ],
518
+ };
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(rg:*)",
5
- "Bash(mkdir:*)"
6
- ],
7
- "deny": []
8
- }
9
- }