piper-utils 1.1.68 → 1.1.69

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": "piper-utils",
3
- "version": "1.1.68",
3
+ "version": "1.1.69",
4
4
  "description": "Utility library for Piper",
5
5
  "main": "bin/main.js",
6
6
  "scripts": {
@@ -1,99 +1,99 @@
1
- import _ from 'lodash';
2
- import { ensurePartnerScope } from './accessContext.js';
3
- import { errorList } from '../../../requestResponse/errorCodes.js';
4
-
5
- /**
6
- * Scope a WHERE clause to the partner's own business (partnerBusinessId).
7
- * Use for "mine" resources: deal, activity, application, template.
8
- *
9
- * global → deletes where.businessId (super sees everything)
10
- * partner → sets where.businessId = partnerBusinessId
11
- * throws partnerNotConfigured if partnerBusinessId is missing
12
- * standard → no-op (createFilters already handled)
13
- *
14
- * @param {object} where - Sequelize WHERE clause to mutate.
15
- * @param {object} access - Access object from resolveAccess.
16
- * @param {{ getPartnerById: Function }} deps
17
- */
18
- export async function scopeToOwnBusiness(where, access, { getPartnerById } = {}) {
19
- if (!access) {
20
- throw errorList.unauthorized;
21
- }
22
- if (access.level === 'global') {
23
- delete where.businessId;
24
- return;
25
- }
26
- if (access.level === 'partner') {
27
- await ensurePartnerScope(access, { getPartnerById });
28
- if (!access.partnerBusinessId) {
29
- throw errorList.partnerNotConfigured;
30
- }
31
- where.businessId = access.partnerBusinessId;
32
- return;
33
- }
34
- // standard → no-op (createFilters already injected from JWT)
35
- }
36
-
37
- /**
38
- * Scope a WHERE clause to the partner's portfolio (businessIds array).
39
- * Use for "book" resources (future transaction lists, merchant inbox views).
40
- *
41
- * global → deletes where.businessId
42
- * partner → sets where.businessId = businessIds[]
43
- * standard → no-op
44
- *
45
- * @param {object} where - Sequelize WHERE clause to mutate.
46
- * @param {object} access - Access object from resolveAccess.
47
- * @param {{ getPartnerById: Function }} deps
48
- */
49
- export async function scopeToPartnerBook(where, access, { getPartnerById } = {}) {
50
- if (!access) {
51
- throw errorList.unauthorized;
52
- }
53
- if (access.level === 'global') {
54
- delete where.businessId;
55
- return;
56
- }
57
- if (access.level === 'partner') {
58
- await ensurePartnerScope(access, { getPartnerById });
59
- where.businessId = access.businessIds || [];
60
- return;
61
- }
62
- // standard → no-op
63
- }
64
-
65
- /**
66
- * Scope to the union of partnerBusinessId + businessIds.
67
- * Use for tickets — partner sees own tickets + portfolio merchants' tickets.
68
- *
69
- * global → deletes where.businessId
70
- * partner → sets where.businessId = [partnerBusinessId, ...businessIds]
71
- * standard → sets where.businessId = access.businessIds
72
- * (explicitly handled here because ticket routes may not call
73
- * createFilters before scoping, e.g. delete/resolve handlers)
74
- *
75
- * @param {object} where - Sequelize WHERE clause to mutate.
76
- * @param {object} access - Access object from resolveAccess.
77
- * @param {{ getPartnerById: Function }} deps
78
- */
79
- export async function scopeToBookUnionOwn(where, access, { getPartnerById } = {}) {
80
- if (!access) {
81
- throw errorList.unauthorized;
82
- }
83
- if (access.level === 'global') {
84
- delete where.businessId;
85
- return;
86
- }
87
- if (access.level === 'partner') {
88
- await ensurePartnerScope(access, { getPartnerById });
89
- const ids = _.uniq(
90
- [access.partnerBusinessId, ...(access.businessIds || [])].filter(Boolean)
91
- );
92
- where.businessId = ids;
93
- return;
94
- }
95
- if (access.level === 'standard') {
96
- where.businessId = access.businessIds;
97
- return;
98
- }
99
- }
1
+ import _ from 'lodash';
2
+ import { ensurePartnerScope } from './accessContext.js';
3
+ import { errorList } from '../../../requestResponse/errorCodes.js';
4
+
5
+ /**
6
+ * Scope a WHERE clause to the partner's own business (partnerBusinessId).
7
+ * Use for "mine" resources: deal, activity, application, template.
8
+ *
9
+ * global → deletes where.businessId (super sees everything)
10
+ * partner → sets where.businessId = partnerBusinessId
11
+ * throws partnerNotConfigured if partnerBusinessId is missing
12
+ * standard → no-op (createFilters already handled)
13
+ *
14
+ * @param {object} where - Sequelize WHERE clause to mutate.
15
+ * @param {object} access - Access object from resolveAccess.
16
+ * @param {{ getPartnerById: Function }} deps
17
+ */
18
+ export async function scopeToOwnBusiness(where, access, { getPartnerById } = {}) {
19
+ if (!access) {
20
+ throw errorList.unauthorized;
21
+ }
22
+ if (access.level === 'global') {
23
+ delete where.businessId;
24
+ return;
25
+ }
26
+ if (access.level === 'partner') {
27
+ await ensurePartnerScope(access, { getPartnerById });
28
+ if (!access.partnerBusinessId) {
29
+ throw errorList.partnerNotConfigured;
30
+ }
31
+ where.businessId = access.partnerBusinessId;
32
+ return;
33
+ }
34
+ // standard → no-op (createFilters already injected from JWT)
35
+ }
36
+
37
+ /**
38
+ * Scope a WHERE clause to the partner's portfolio (businessIds array).
39
+ * Use for "book" resources (future transaction lists, merchant inbox views).
40
+ *
41
+ * global → deletes where.businessId
42
+ * partner → sets where.businessId = businessIds[]
43
+ * standard → no-op
44
+ *
45
+ * @param {object} where - Sequelize WHERE clause to mutate.
46
+ * @param {object} access - Access object from resolveAccess.
47
+ * @param {{ getPartnerById: Function }} deps
48
+ */
49
+ export async function scopeToPartnerBook(where, access, { getPartnerById } = {}) {
50
+ if (!access) {
51
+ throw errorList.unauthorized;
52
+ }
53
+ if (access.level === 'global') {
54
+ delete where.businessId;
55
+ return;
56
+ }
57
+ if (access.level === 'partner') {
58
+ await ensurePartnerScope(access, { getPartnerById });
59
+ where.businessId = access.businessIds || [];
60
+ return;
61
+ }
62
+ // standard → no-op
63
+ }
64
+
65
+ /**
66
+ * Scope to the union of partnerBusinessId + businessIds.
67
+ * Use for tickets — partner sees own tickets + portfolio merchants' tickets.
68
+ *
69
+ * global → deletes where.businessId
70
+ * partner → sets where.businessId = [partnerBusinessId, ...businessIds]
71
+ * standard → sets where.businessId = access.businessIds
72
+ * (explicitly handled here because ticket routes may not call
73
+ * createFilters before scoping, e.g. delete/resolve handlers)
74
+ *
75
+ * @param {object} where - Sequelize WHERE clause to mutate.
76
+ * @param {object} access - Access object from resolveAccess.
77
+ * @param {{ getPartnerById: Function }} deps
78
+ */
79
+ export async function scopeToBookUnionOwn(where, access, { getPartnerById } = {}) {
80
+ if (!access) {
81
+ throw errorList.unauthorized;
82
+ }
83
+ if (access.level === 'global') {
84
+ delete where.businessId;
85
+ return;
86
+ }
87
+ if (access.level === 'partner') {
88
+ await ensurePartnerScope(access, { getPartnerById });
89
+ const ids = _.uniq(
90
+ [access.partnerBusinessId, ...(access.businessIds || [])].filter(Boolean)
91
+ );
92
+ where.businessId = ids;
93
+ return;
94
+ }
95
+ if (access.level === 'standard') {
96
+ where.businessId = access.businessIds;
97
+ return;
98
+ }
99
+ }
@@ -0,0 +1,287 @@
1
+ import {
2
+ scopeToOwnBusiness,
3
+ scopeToPartnerBook,
4
+ scopeToBookUnionOwn
5
+ } from './accessScope.js';
6
+ import { errorList } from '../../../requestResponse/errorCodes.js';
7
+
8
+ // Why these tests exist
9
+ // ---------------------
10
+ // The three scope helpers were rewritten to accept an explicit `requested`
11
+ // businessIds array from the caller's query string, and to intersect that
12
+ // against the role's allowed set instead of unconditionally overwriting
13
+ // where.businessId. The header "My Business" filter for super users depends
14
+ // on the new intersection — these tests pin the contract.
15
+ //
16
+ // "requested" is what the route handler extracts via getRequestedBusinessIds(event)
17
+ // and forwards. Empty array = no explicit filter (back-compat path).
18
+
19
+ describe('accessScope helpers', () => {
20
+
21
+ describe('scopeToBookUnionOwn — global (super)', () => {
22
+ it('deletes where.businessId when no explicit filter is requested', async () => {
23
+ // Arrange — super with no header pick → caller sends nothing
24
+ const where = { businessId: 'pre-existing-set-by-createFilters', active: true };
25
+ const access = { level: 'global' };
26
+
27
+ // Act
28
+ await scopeToBookUnionOwn(where, access, []);
29
+
30
+ // Assert — businessId stripped so the query is unconstrained;
31
+ // unrelated keys (active) survive
32
+ expect(where.businessId).toBeUndefined();
33
+ expect(where.active).toBe(true);
34
+ });
35
+
36
+ it('honors the requested businessIds when super passes them explicitly', async () => {
37
+ // Arrange — super picked business "X" in the header pill
38
+ const where = {};
39
+ const access = { level: 'global' };
40
+
41
+ // Act
42
+ await scopeToBookUnionOwn(where, access, ['X']);
43
+
44
+ // Assert — backend respects the explicit filter (the symptom from
45
+ // the user-reported "Deals list doesn't filter to My Business" bug)
46
+ expect(where.businessId).toEqual(['X']);
47
+ });
48
+ });
49
+
50
+ describe('scopeToBookUnionOwn — partner', () => {
51
+ it('returns the full union when no explicit filter is requested', async () => {
52
+ // Arrange — partner with own=O and portfolio=[A, B]; pre-resolved scope
53
+ const where = {};
54
+ const access = {
55
+ level: 'partner',
56
+ partnerBusinessId: 'O',
57
+ businessIds: ['A', 'B']
58
+ };
59
+
60
+ // Act
61
+ await scopeToBookUnionOwn(where, access, []);
62
+
63
+ // Assert — union preserved (own + portfolio, unique)
64
+ expect(where.businessId).toEqual(['O', 'A', 'B']);
65
+ });
66
+
67
+ it('intersects requested with the union when partner filters explicitly', async () => {
68
+ // Arrange — partner asks for A only, A is in portfolio
69
+ const where = {};
70
+ const access = {
71
+ level: 'partner',
72
+ partnerBusinessId: 'O',
73
+ businessIds: ['A', 'B']
74
+ };
75
+
76
+ // Act
77
+ await scopeToBookUnionOwn(where, access, ['A']);
78
+
79
+ // Assert — only the requested+allowed entry survives
80
+ expect(where.businessId).toEqual(['A']);
81
+ });
82
+
83
+ it('returns empty when requested business is outside the partner union', async () => {
84
+ // Arrange — partner asks for Z which is not in own or portfolio
85
+ const where = {};
86
+ const access = {
87
+ level: 'partner',
88
+ partnerBusinessId: 'O',
89
+ businessIds: ['A', 'B']
90
+ };
91
+
92
+ // Act
93
+ await scopeToBookUnionOwn(where, access, ['Z']);
94
+
95
+ // Assert — empty array → IN () → silent zero rows. Intentional;
96
+ // confirms a partner can't sneak past their scope via query param.
97
+ expect(where.businessId).toEqual([]);
98
+ });
99
+
100
+ it('lazy-loads partnerBusinessId/businessIds via getPartnerById on first call', async () => {
101
+ // Arrange — access is "fresh" (nulls), like resolveAccess hands out
102
+ const where = {};
103
+ const access = { level: 'partner', partnerId: 'p1', partnerBusinessId: null, businessIds: null };
104
+ const getPartnerById = jasmine.createSpy('getPartnerById').and.resolveTo({
105
+ partnerBusinessId: 'O',
106
+ businessIds: ['A']
107
+ });
108
+
109
+ // Act
110
+ await scopeToBookUnionOwn(where, access, [], { getPartnerById });
111
+
112
+ // Assert — DB hit happened and where.businessId is the resolved union
113
+ expect(getPartnerById).toHaveBeenCalledWith('p1');
114
+ expect(where.businessId).toEqual(['O', 'A']);
115
+ });
116
+ });
117
+
118
+ describe('scopeToBookUnionOwn — standard (merchant)', () => {
119
+ it('falls back to access.businessIds when no explicit filter is requested', async () => {
120
+ // Arrange — standard merchant has JWT businesses S1, S2
121
+ const where = {};
122
+ const access = { level: 'standard', businessIds: ['S1', 'S2'] };
123
+
124
+ // Act
125
+ await scopeToBookUnionOwn(where, access, []);
126
+
127
+ // Assert
128
+ expect(where.businessId).toEqual(['S1', 'S2']);
129
+ });
130
+
131
+ it('intersects requested with JWT businesses when standard filters explicitly', async () => {
132
+ // Arrange — merchant asks for S1 (in their JWT)
133
+ const where = {};
134
+ const access = { level: 'standard', businessIds: ['S1', 'S2'] };
135
+
136
+ // Act
137
+ await scopeToBookUnionOwn(where, access, ['S1']);
138
+
139
+ // Assert
140
+ expect(where.businessId).toEqual(['S1']);
141
+ });
142
+
143
+ it('returns empty when standard asks for a business outside their JWT', async () => {
144
+ // Arrange — IDOR attempt: merchant on S1 asks for someone else's business "X"
145
+ const where = {};
146
+ const access = { level: 'standard', businessIds: ['S1', 'S2'] };
147
+
148
+ // Act
149
+ await scopeToBookUnionOwn(where, access, ['X']);
150
+
151
+ // Assert — empty result, no cross-tenant leak
152
+ expect(where.businessId).toEqual([]);
153
+ });
154
+ });
155
+
156
+ describe('scopeToBookUnionOwn — defensive', () => {
157
+ it('throws unauthorized when access is null', async () => {
158
+ // Arrange — never expected to happen, but guard rather than corrupt the query
159
+ let thrown;
160
+
161
+ // Act
162
+ try {
163
+ await scopeToBookUnionOwn({}, null, []);
164
+ } catch (e) {
165
+ thrown = e;
166
+ }
167
+
168
+ // Assert
169
+ expect(thrown).toBe(errorList.unauthorized);
170
+ });
171
+ });
172
+
173
+ describe('scopeToOwnBusiness', () => {
174
+ it('sets where.businessId to partnerBusinessId for partners (no explicit filter)', async () => {
175
+ // Arrange
176
+ const where = {};
177
+ const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A'] };
178
+
179
+ // Act
180
+ await scopeToOwnBusiness(where, access, []);
181
+
182
+ // Assert — own resource scope is single-value, not union
183
+ expect(where.businessId).toBe('O');
184
+ });
185
+
186
+ it('intersects requested with the partner own businessId', async () => {
187
+ // Arrange — partner asks for "O" (their own) — valid
188
+ const where = {};
189
+ const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A'] };
190
+
191
+ // Act
192
+ await scopeToOwnBusiness(where, access, ['O']);
193
+
194
+ // Assert — intersection ['O'] ∩ ['O'] = ['O']
195
+ expect(where.businessId).toEqual(['O']);
196
+ });
197
+
198
+ it('returns empty when requested business is not the partner own', async () => {
199
+ // Arrange — partner asks for portfolio business "A" but this is the OWN scope
200
+ const where = {};
201
+ const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A'] };
202
+
203
+ // Act
204
+ await scopeToOwnBusiness(where, access, ['A']);
205
+
206
+ // Assert — own scope rejects even valid-for-them businesses outside "own"
207
+ expect(where.businessId).toEqual([]);
208
+ });
209
+
210
+ it('throws partnerNotConfigured when partnerBusinessId is missing', async () => {
211
+ // Arrange — bad partner setup: portfolio set but no own businessId.
212
+ // Threw originally too, but now after the new requested param flows through.
213
+ const where = {};
214
+ const access = { level: 'partner', partnerBusinessId: null, businessIds: ['A'] };
215
+ let thrown;
216
+
217
+ // Act
218
+ try {
219
+ await scopeToOwnBusiness(where, access, []);
220
+ } catch (e) {
221
+ thrown = e;
222
+ }
223
+
224
+ // Assert
225
+ expect(thrown).toBe(errorList.partnerNotConfigured);
226
+ });
227
+
228
+ it('global with explicit request → where.businessId = requested', async () => {
229
+ // Arrange
230
+ const where = {};
231
+
232
+ // Act
233
+ await scopeToOwnBusiness(where, { level: 'global' }, ['X']);
234
+
235
+ // Assert
236
+ expect(where.businessId).toEqual(['X']);
237
+ });
238
+
239
+ it('global with no request → where.businessId deleted', async () => {
240
+ // Arrange
241
+ const where = { businessId: 'stale' };
242
+
243
+ // Act
244
+ await scopeToOwnBusiness(where, { level: 'global' }, []);
245
+
246
+ // Assert
247
+ expect(where.businessId).toBeUndefined();
248
+ });
249
+ });
250
+
251
+ describe('scopeToPartnerBook', () => {
252
+ it('returns full portfolio for partners with no explicit request', async () => {
253
+ // Arrange
254
+ const where = {};
255
+ const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A', 'B'] };
256
+
257
+ // Act
258
+ await scopeToPartnerBook(where, access, []);
259
+
260
+ // Assert — book is portfolio only (no own)
261
+ expect(where.businessId).toEqual(['A', 'B']);
262
+ });
263
+
264
+ it('intersects requested with portfolio (excludes partner own)', async () => {
265
+ // Arrange — partner asks for "O" (own) and "A" (portfolio)
266
+ const where = {};
267
+ const access = { level: 'partner', partnerBusinessId: 'O', businessIds: ['A', 'B'] };
268
+
269
+ // Act
270
+ await scopeToPartnerBook(where, access, ['O', 'A']);
271
+
272
+ // Assert — book scope drops "own" even when explicitly requested
273
+ expect(where.businessId).toEqual(['A']);
274
+ });
275
+
276
+ it('global no request → deletes where.businessId', async () => {
277
+ // Arrange
278
+ const where = { businessId: 'stale' };
279
+
280
+ // Act
281
+ await scopeToPartnerBook(where, { level: 'global' }, []);
282
+
283
+ // Assert
284
+ expect(where.businessId).toBeUndefined();
285
+ });
286
+ });
287
+ });
@@ -1,38 +1,38 @@
1
- import { ensurePartnerScope } from './accessContext.js';
2
- import { scopeToOwnBusiness, scopeToPartnerBook, scopeToBookUnionOwn } from './accessScope.js';
3
- import {
4
- assertCanWriteOwnBusiness,
5
- assertCanWriteBookBusiness,
6
- assertCanWriteBookUnionOwn,
7
- stampOwnBusinessId
8
- } from './accessWrites.js';
9
-
10
- /**
11
- * Factory that pre-binds getPartnerById into all scope/assert/stamp helpers.
12
- * Bind once at module level, then call without repeating the injection:
13
- *
14
- * import { createAccessHelpers } from 'piper-utils';
15
- * import { getPartnerById } from '../partner/partner.js';
16
- *
17
- * const {
18
- * scopeToOwnBusiness,
19
- * stampOwnBusinessId,
20
- * assertCanWriteOwnBusiness
21
- * } = createAccessHelpers({ getPartnerById });
22
- *
23
- * @param {{ getPartnerById: Function }} deps
24
- * @returns {object} Pre-bound helpers.
25
- */
26
- export function createAccessHelpers({ getPartnerById }) {
27
- const deps = { getPartnerById };
28
- return {
29
- ensurePartnerScope: (access) => ensurePartnerScope(access, deps),
30
- scopeToOwnBusiness: (where, access) => scopeToOwnBusiness(where, access, deps),
31
- scopeToPartnerBook: (where, access) => scopeToPartnerBook(where, access, deps),
32
- scopeToBookUnionOwn: (where, access) => scopeToBookUnionOwn(where, access, deps),
33
- assertCanWriteOwnBusiness: (access, businessId) => assertCanWriteOwnBusiness(access, businessId, deps),
34
- assertCanWriteBookBusiness: (access, businessId) => assertCanWriteBookBusiness(access, businessId, deps),
35
- assertCanWriteBookUnionOwn: (access, businessId) => assertCanWriteBookUnionOwn(access, businessId, deps),
36
- stampOwnBusinessId: (access, body) => stampOwnBusinessId(access, body, deps)
37
- };
38
- }
1
+ import { ensurePartnerScope } from './accessContext.js';
2
+ import { scopeToOwnBusiness, scopeToPartnerBook, scopeToBookUnionOwn } from './accessScope.js';
3
+ import {
4
+ assertCanWriteOwnBusiness,
5
+ assertCanWriteBookBusiness,
6
+ assertCanWriteBookUnionOwn,
7
+ stampOwnBusinessId
8
+ } from './accessWrites.js';
9
+
10
+ /**
11
+ * Factory that pre-binds getPartnerById into all scope/assert/stamp helpers.
12
+ * Bind once at module level, then call without repeating the injection:
13
+ *
14
+ * import { createAccessHelpers } from 'piper-utils';
15
+ * import { getPartnerById } from '../partner/partner.js';
16
+ *
17
+ * const {
18
+ * scopeToOwnBusiness,
19
+ * stampOwnBusinessId,
20
+ * assertCanWriteOwnBusiness
21
+ * } = createAccessHelpers({ getPartnerById });
22
+ *
23
+ * @param {{ getPartnerById: Function }} deps
24
+ * @returns {object} Pre-bound helpers.
25
+ */
26
+ export function createAccessHelpers({ getPartnerById }) {
27
+ const deps = { getPartnerById };
28
+ return {
29
+ ensurePartnerScope: (access) => ensurePartnerScope(access, deps),
30
+ scopeToOwnBusiness: (where, access) => scopeToOwnBusiness(where, access, deps),
31
+ scopeToPartnerBook: (where, access) => scopeToPartnerBook(where, access, deps),
32
+ scopeToBookUnionOwn: (where, access) => scopeToBookUnionOwn(where, access, deps),
33
+ assertCanWriteOwnBusiness: (access, businessId) => assertCanWriteOwnBusiness(access, businessId, deps),
34
+ assertCanWriteBookBusiness: (access, businessId) => assertCanWriteBookBusiness(access, businessId, deps),
35
+ assertCanWriteBookUnionOwn: (access, businessId) => assertCanWriteBookUnionOwn(access, businessId, deps),
36
+ stampOwnBusinessId: (access, body) => stampOwnBusinessId(access, body, deps)
37
+ };
38
+ }
@@ -125,6 +125,12 @@ export function failure(body = {}, options) {
125
125
  }
126
126
  const newBody = _.merge(NORMAL_ERROR, cleanedErrorBody);
127
127
 
128
+ const debugLogging = process.env.UTIL_LOG === 'LOG_ALL' || process.env.BUILD_ENV === 'test';
129
+ if (!debugLogging && newBody.statusCode >= 500) {
130
+ // Log the raw error object so thrown Error stacks survive (JSON.stringify drops them)
131
+ console.error('------->UTIL ERROR:', newBody.statusCode, _.get(body, 'message', ''), body);
132
+ }
133
+
128
134
  return buildResponse(newBody.statusCode, newBody);
129
135
  }
130
136
 
@@ -266,7 +272,6 @@ export function detectJoyError(body) {
266
272
  return acc;
267
273
  }, '');
268
274
 
269
- console.error('USER VALIDATION ERROR:', body);
270
275
  const msg = (joyError?.message || '') + v;
271
276
 
272
277
  if (msg) {