@open-mercato/core 0.6.3-develop.3562.1.71049275ff → 0.6.3-develop.3574.1.f3c5dcf345

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/dist/helpers/integration/queue-runner.js +15 -3
  2. package/dist/helpers/integration/queue-runner.js.map +2 -2
  3. package/dist/helpers/integration/queue.js +1 -1
  4. package/dist/helpers/integration/queue.js.map +2 -2
  5. package/dist/modules/auth/api/roles/acl/route.js +37 -1
  6. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  7. package/dist/modules/auth/api/roles/route.js +36 -2
  8. package/dist/modules/auth/api/roles/route.js.map +2 -2
  9. package/dist/modules/auth/api/users/acl/route.js +36 -1
  10. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  11. package/dist/modules/auth/api/users/route.js +40 -2
  12. package/dist/modules/auth/api/users/route.js.map +2 -2
  13. package/dist/modules/auth/commands/users.js +3 -0
  14. package/dist/modules/auth/commands/users.js.map +2 -2
  15. package/dist/modules/auth/lib/grantChecks.js +97 -2
  16. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  17. package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
  18. package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
  19. package/dist/modules/translations/components/TranslationManager.js +9 -4
  20. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  21. package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
  22. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  23. package/jest.config.cjs +1 -0
  24. package/package.json +7 -7
  25. package/src/helpers/integration/queue-runner.ts +21 -2
  26. package/src/helpers/integration/queue.ts +1 -1
  27. package/src/modules/auth/api/roles/acl/route.ts +39 -1
  28. package/src/modules/auth/api/roles/route.ts +38 -2
  29. package/src/modules/auth/api/users/acl/route.ts +41 -1
  30. package/src/modules/auth/api/users/route.ts +41 -2
  31. package/src/modules/auth/commands/users.ts +3 -0
  32. package/src/modules/auth/lib/grantChecks.ts +116 -2
  33. package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
  34. package/src/modules/translations/components/TranslationManager.tsx +9 -4
  35. package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
@@ -1,8 +1,8 @@
1
1
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
2
  import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
3
3
  import { hasFeature } from '@open-mercato/shared/security/features'
4
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
- import { Role, RoleAcl } from '@open-mercato/core/modules/auth/data/entities'
4
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { Role, RoleAcl, UserAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
 
8
8
  type ActorAcl = {
@@ -33,6 +33,16 @@ type FeatureGrantCheckInput = GrantCheckContext & {
33
33
  organizations?: string[] | null
34
34
  }
35
35
 
36
+ type SuperAdminUserTargetInput = GrantCheckContext & {
37
+ targetUserId: string
38
+ actorIsSuperAdmin?: boolean
39
+ }
40
+
41
+ type SuperAdminRoleTargetInput = GrantCheckContext & {
42
+ targetRoleId: string
43
+ actorIsSuperAdmin?: boolean
44
+ }
45
+
36
46
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
37
47
 
38
48
  export async function assertActorCanGrantRoleTokens(input: RoleTokenGrantCheckInput): Promise<Role[]> {
@@ -99,6 +109,110 @@ export function normalizeGrantFeatureList(features: unknown): string[] {
99
109
  return normalizeStringList(features)
100
110
  }
101
111
 
112
+ export async function assertActorCanModifySuperAdminUserTarget(input: SuperAdminUserTargetInput): Promise<void> {
113
+ const actorIsSuperAdmin = await resolveActorIsSuperAdmin(input)
114
+ if (actorIsSuperAdmin) return
115
+ const targetIsSuperAdmin = await isUserEffectivelySuperAdmin(input.em, input.targetUserId)
116
+ if (targetIsSuperAdmin) {
117
+ throw forbidden('Only super administrators can modify super administrator accounts.')
118
+ }
119
+ }
120
+
121
+ export async function assertActorCanModifySuperAdminRoleTarget(input: SuperAdminRoleTargetInput): Promise<void> {
122
+ const actorIsSuperAdmin = await resolveActorIsSuperAdmin(input)
123
+ if (actorIsSuperAdmin) return
124
+ const targetIsSuperAdmin = await isRoleEffectivelySuperAdmin(input.em, input.targetRoleId)
125
+ if (targetIsSuperAdmin) {
126
+ throw forbidden('Only super administrators can modify super administrator roles.')
127
+ }
128
+ }
129
+
130
+ async function resolveActorIsSuperAdmin(input: GrantCheckContext & { actorIsSuperAdmin?: boolean }): Promise<boolean> {
131
+ if (typeof input.actorIsSuperAdmin === 'boolean') return input.actorIsSuperAdmin
132
+ const acl = await loadActorAcl(input)
133
+ return acl.isSuperAdmin
134
+ }
135
+
136
+ export async function isUserEffectivelySuperAdmin(em: EntityManager, userId: string): Promise<boolean> {
137
+ const directGrant = await em.findOne(
138
+ UserAcl,
139
+ { user: userId as unknown, isSuperAdmin: true } as FilterQuery<UserAcl>,
140
+ )
141
+ if (directGrant && (directGrant as { isSuperAdmin?: boolean }).isSuperAdmin === true) return true
142
+ const links = await findWithDecryption(
143
+ em,
144
+ UserRole,
145
+ { user: userId as unknown } as FilterQuery<UserRole>,
146
+ { populate: ['role'] },
147
+ { tenantId: null, organizationId: null },
148
+ )
149
+ const roleIds = (Array.isArray(links) ? links : [])
150
+ .map((link) => {
151
+ const role = (link as { role?: { id?: unknown } | string | null }).role
152
+ if (!role) return null
153
+ if (typeof role === 'string') return role
154
+ return role.id ? String(role.id) : null
155
+ })
156
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
157
+ if (!roleIds.length) return false
158
+ const roleGrant = await em.findOne(
159
+ RoleAcl,
160
+ { role: { $in: roleIds } as unknown, isSuperAdmin: true } as FilterQuery<RoleAcl>,
161
+ )
162
+ return !!roleGrant && (roleGrant as { isSuperAdmin?: boolean }).isSuperAdmin === true
163
+ }
164
+
165
+ export async function isRoleEffectivelySuperAdmin(em: EntityManager, roleId: string): Promise<boolean> {
166
+ const grant = await em.findOne(
167
+ RoleAcl,
168
+ { role: roleId as unknown, isSuperAdmin: true } as FilterQuery<RoleAcl>,
169
+ )
170
+ return !!grant && (grant as { isSuperAdmin?: boolean }).isSuperAdmin === true
171
+ }
172
+
173
+ export async function listSuperAdminUserIds(em: EntityManager, tenantId: string | null): Promise<Set<string>> {
174
+ const ids = new Set<string>()
175
+ const userAclFilter: Record<string, unknown> = { isSuperAdmin: true }
176
+ if (tenantId) userAclFilter.tenantId = tenantId
177
+ const userAcls = await em.find(UserAcl, userAclFilter as FilterQuery<UserAcl>)
178
+ for (const acl of userAcls) {
179
+ const userRef = (acl as { user?: { id?: unknown } | string | null }).user
180
+ const userId = userRef && typeof userRef === 'object'
181
+ ? userRef.id
182
+ : userRef
183
+ if (userId) ids.add(String(userId))
184
+ }
185
+ const roleAcls = await em.find(
186
+ RoleAcl,
187
+ { isSuperAdmin: true } as FilterQuery<RoleAcl>,
188
+ )
189
+ const roleIds = roleAcls
190
+ .map((acl) => {
191
+ const roleRef = (acl as { role?: { id?: unknown } | string | null }).role
192
+ if (!roleRef) return null
193
+ if (typeof roleRef === 'string') return roleRef
194
+ return roleRef.id ? String(roleRef.id) : null
195
+ })
196
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
197
+ if (roleIds.length) {
198
+ const links = await findWithDecryption(
199
+ em,
200
+ UserRole,
201
+ { role: { $in: roleIds } as unknown } as FilterQuery<UserRole>,
202
+ {},
203
+ { tenantId: null, organizationId: null },
204
+ )
205
+ for (const link of Array.isArray(links) ? links : []) {
206
+ const userRef = (link as { user?: { id?: unknown } | string | null }).user
207
+ const userId = userRef && typeof userRef === 'object'
208
+ ? userRef.id
209
+ : userRef
210
+ if (userId) ids.add(String(userId))
211
+ }
212
+ }
213
+ return ids
214
+ }
215
+
102
216
  async function loadActorAcl(input: GrantCheckContext): Promise<ActorAcl> {
103
217
  const actorUserId = normalizeNullableString(input.actorUserId)
104
218
  if (!actorUserId) throw forbidden('Not authorized to grant ACL privileges.')
@@ -1,4 +1,5 @@
1
1
  import type { ComparisonOperator, LogicalOperator } from '../data/validators'
2
+ import { testLinearRegex } from '@open-mercato/shared/lib/regex/linear'
2
3
  import { getNestedValue, resolveSpecialValue } from './value-resolver'
3
4
 
4
5
  /**
@@ -319,11 +320,8 @@ function endsWith(str: any, suffix: any): boolean {
319
320
  return String(str).endsWith(String(suffix))
320
321
  }
321
322
 
322
- /**
323
- * Security limits for regex operations
324
- */
325
- const REGEX_TIMEOUT_MS = 100
326
323
  const MAX_REGEX_LENGTH = 200
324
+ const MAX_REGEX_INPUT_LENGTH = 10_000
327
325
 
328
326
  /**
329
327
  * Check if string matches regex pattern (with ReDoS protection)
@@ -345,18 +343,16 @@ function matches(str: any, pattern: any): boolean {
345
343
  return false
346
344
  }
347
345
 
348
- const regex = new RegExp(patternStr)
349
346
  const testStr = String(str)
350
-
351
- // Use a simple timeout mechanism
352
- const startTime = Date.now()
353
- const result = regex.test(testStr)
354
-
355
- if (Date.now() - startTime > REGEX_TIMEOUT_MS) {
356
- throw new Error('Regex execution timeout - potential ReDoS pattern detected')
347
+ if (testStr.length > MAX_REGEX_INPUT_LENGTH) {
348
+ return false
357
349
  }
358
350
 
359
- return result
351
+ const result = testLinearRegex(patternStr, testStr, {
352
+ maxPatternLength: MAX_REGEX_LENGTH,
353
+ maxInputLength: MAX_REGEX_INPUT_LENGTH,
354
+ })
355
+ return result.ok ? result.matched : false
360
356
  } catch (error) {
361
357
  // Log the error for debugging but don't expose to user
362
358
  const message = error instanceof Error ? error.message : 'Unknown error'
@@ -69,6 +69,7 @@ export function TranslationManager({
69
69
  const [editedTranslations, setEditedTranslations] = React.useState<Record<string, Record<string, string>>>({})
70
70
  const editedTranslationsRef = React.useRef<Record<string, Record<string, string>>>({})
71
71
  const [hasUserEdited, setHasUserEdited] = React.useState(false)
72
+ const hasUserEditedRef = React.useRef(false)
72
73
 
73
74
  const entityType = isEmbedded ? (propEntityType ?? '') : selectedEntityType
74
75
  const recordId = isEmbedded ? (propRecordId ?? '') : selectedRecordId
@@ -191,11 +192,11 @@ export function TranslationManager({
191
192
 
192
193
  React.useEffect(() => {
193
194
  const sig = translationSignature
194
- if (sig === lastTranslationSignatureRef.current && hasUserEdited) return
195
+ if (sig === lastTranslationSignatureRef.current && hasUserEditedRef.current) return
195
196
  lastTranslationSignatureRef.current = sig
196
197
 
197
198
  if (!translationData?.translations) {
198
- if (!hasUserEdited) {
199
+ if (!hasUserEditedRef.current) {
199
200
  editedTranslationsRef.current = {}
200
201
  setEditedTranslations({})
201
202
  }
@@ -210,11 +211,11 @@ export function TranslationManager({
210
211
  parsed[locale][key] = typeof val === 'string' ? val : ''
211
212
  }
212
213
  }
213
- if (!hasUserEdited) {
214
+ if (!hasUserEditedRef.current) {
214
215
  editedTranslationsRef.current = parsed
215
216
  setEditedTranslations(parsed)
216
217
  }
217
- }, [translationSignature, translationData, hasUserEdited])
218
+ }, [translationSignature, translationData])
218
219
 
219
220
  const mutation = useMutation({
220
221
  mutationFn: async () => {
@@ -252,6 +253,7 @@ export function TranslationManager({
252
253
  },
253
254
  onSuccess: () => {
254
255
  flash(t('translations.manager.flash.saved', 'Translations saved'), 'success')
256
+ hasUserEditedRef.current = false
255
257
  setHasUserEdited(false)
256
258
  void refetchTranslation()
257
259
  },
@@ -262,6 +264,7 @@ export function TranslationManager({
262
264
  })
263
265
 
264
266
  const updateFieldValue = (locale: string, fieldKey: string, value: string) => {
267
+ hasUserEditedRef.current = true
265
268
  setHasUserEdited(true)
266
269
  const next = {
267
270
  ...editedTranslationsRef.current,
@@ -288,6 +291,7 @@ export function TranslationManager({
288
291
  value={selectedRecordId}
289
292
  onChange={(next) => {
290
293
  setSelectedRecordId(next)
294
+ hasUserEditedRef.current = false
291
295
  setHasUserEdited(false)
292
296
  }}
293
297
  placeholder={t('translations.manager.searchRecords', 'Search records...')}
@@ -482,6 +486,7 @@ export function TranslationManager({
482
486
  onChange={(next) => {
483
487
  setSelectedEntityType(next)
484
488
  setSelectedRecordId('')
489
+ hasUserEditedRef.current = false
485
490
  setHasUserEdited(false)
486
491
  }}
487
492
  placeholder={t('translations.manager.placeholder', 'Select an entity')}
@@ -6,9 +6,12 @@
6
6
  */
7
7
 
8
8
  import type { EntityManager } from '@mikro-orm/core'
9
+ import type { EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'
9
10
  import type { AwilixContainer } from 'awilix'
10
11
  import type { CacheService } from '@open-mercato/cache'
11
12
  import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
13
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
14
+ import { testLinearRegex } from '@open-mercato/shared/lib/regex/linear'
12
15
  import {
13
16
  WorkflowEventTrigger,
14
17
  WorkflowDefinition,
@@ -257,11 +260,12 @@ function evaluateCondition(condition: TriggerFilterCondition, payload: Record<st
257
260
  if (typeof value !== 'string' || typeof expected !== 'string') return false
258
261
  if (value.length > MAX_WORKFLOW_REGEX_INPUT_LENGTH) return false
259
262
  if (!isSafeWorkflowRegexPattern(expected)) return false
260
- try {
261
- const regex = new RegExp(expected)
262
- return regex.test(value)
263
- } catch {
264
- return false
263
+ {
264
+ const result = testLinearRegex(expected, value, {
265
+ maxPatternLength: MAX_WORKFLOW_REGEX_PATTERN_LENGTH,
266
+ maxInputLength: MAX_WORKFLOW_REGEX_INPUT_LENGTH,
267
+ })
268
+ return result.ok ? result.matched : false
265
269
  }
266
270
 
267
271
  default:
@@ -325,7 +329,9 @@ async function loadLegacyTriggers(
325
329
  tenantId: string,
326
330
  organizationId: string
327
331
  ): Promise<UnifiedTrigger[]> {
328
- const legacyTriggers = await em.find(
332
+ const postgresEm = em as unknown as PostgreSqlEntityManager
333
+ const legacyTriggers = await findWithDecryption(
334
+ postgresEm,
329
335
  WorkflowEventTrigger,
330
336
  {
331
337
  tenantId,
@@ -335,16 +341,19 @@ async function loadLegacyTriggers(
335
341
  },
336
342
  {
337
343
  orderBy: { priority: 'DESC', createdAt: 'ASC' },
338
- }
344
+ },
345
+ { tenantId, organizationId },
339
346
  )
340
347
 
341
348
  // Get definitions for these triggers to get workflowId
342
349
  const definitionIds = [...new Set(legacyTriggers.map(t => t.workflowDefinitionId))]
343
- const definitions = definitionIds.length > 0 ? await em.find(WorkflowDefinition, {
350
+ const definitions = definitionIds.length > 0 ? await findWithDecryption(postgresEm, WorkflowDefinition, {
344
351
  id: { $in: definitionIds },
352
+ tenantId,
353
+ organizationId,
345
354
  enabled: true,
346
355
  deletedAt: null,
347
- }) : []
356
+ }, {}, { tenantId, organizationId }) : []
348
357
  const definitionMap = new Map(definitions.map(d => [d.id, d]))
349
358
 
350
359
  return legacyTriggers
@@ -379,15 +388,19 @@ async function loadEmbeddedTriggers(
379
388
  tenantId: string,
380
389
  organizationId: string
381
390
  ): Promise<UnifiedTrigger[]> {
391
+ const postgresEm = em as unknown as PostgreSqlEntityManager
382
392
  // Load all enabled definitions that may have triggers
383
- const definitions = await em.find(
393
+ const definitions = await findWithDecryption(
394
+ postgresEm,
384
395
  WorkflowDefinition,
385
396
  {
386
397
  tenantId,
387
398
  organizationId,
388
399
  enabled: true,
389
400
  deletedAt: null,
390
- }
401
+ },
402
+ {},
403
+ { tenantId, organizationId },
391
404
  )
392
405
 
393
406
  const triggers: UnifiedTrigger[] = []