@open-mercato/core 0.5.1-develop.3045.b4b3320cc2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +21 -1
  3. package/dist/modules/api_keys/api/keys/route.js +9 -0
  4. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  5. package/dist/modules/audit_logs/services/accessLogService.js +13 -0
  6. package/dist/modules/audit_logs/services/accessLogService.js.map +3 -3
  7. package/dist/modules/audit_logs/services/actionLogService.js +6 -5
  8. package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
  9. package/dist/modules/auth/api/roles/acl/route.js +27 -37
  10. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  11. package/dist/modules/auth/api/users/route.js +41 -28
  12. package/dist/modules/auth/api/users/route.js.map +3 -3
  13. package/dist/modules/auth/lib/grantChecks.js +160 -0
  14. package/dist/modules/auth/lib/grantChecks.js.map +7 -0
  15. package/dist/modules/configs/cli.js +11 -0
  16. package/dist/modules/configs/cli.js.map +2 -2
  17. package/dist/modules/configs/lib/touchGeneratedBarrels.js +46 -0
  18. package/dist/modules/configs/lib/touchGeneratedBarrels.js.map +7 -0
  19. package/dist/modules/customers/api/activities/route.js +1 -52
  20. package/dist/modules/customers/api/activities/route.js.map +2 -2
  21. package/dist/modules/customers/api/interactions/counts/route.js +2 -1
  22. package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
  23. package/dist/modules/customers/api/interactions/route.js +21 -1
  24. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  25. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -3
  26. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  27. package/dist/modules/customers/backend/customers/deals/[id]/page.js +5 -1
  28. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  29. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +7 -3
  30. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  31. package/dist/modules/customers/components/detail/ActivitiesCard.js +62 -6
  32. package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
  33. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +21 -6
  34. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
  35. package/dist/modules/customers/components/detail/ActivitiesSection.js +37 -5
  36. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  37. package/dist/modules/customers/components/detail/ActivityCard.js +69 -17
  38. package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
  39. package/dist/modules/customers/components/detail/ActivityHistorySection.js +94 -34
  40. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  41. package/dist/modules/customers/components/detail/ActivityLogTab.js +3 -1
  42. package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
  43. package/dist/modules/customers/components/detail/ActivityTimeline.js +41 -8
  44. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  45. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +19 -6
  46. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
  47. package/dist/modules/customers/components/detail/ActivityTypeSelector.js +4 -3
  48. package/dist/modules/customers/components/detail/ActivityTypeSelector.js.map +2 -2
  49. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +80 -12
  50. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  51. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +65 -10
  52. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  53. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +10 -5
  54. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  55. package/dist/modules/customers/data/validators.js +74 -2
  56. package/dist/modules/customers/data/validators.js.map +2 -2
  57. package/dist/modules/customers/lib/legacyActivityBridge.js +61 -0
  58. package/dist/modules/customers/lib/legacyActivityBridge.js.map +7 -0
  59. package/dist/modules/integrations/data/validators.js +2 -2
  60. package/dist/modules/integrations/data/validators.js.map +2 -2
  61. package/dist/modules/integrations/lib/credentials-service.js +12 -1
  62. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  63. package/dist/modules/messages/commands/actions.js +29 -14
  64. package/dist/modules/messages/commands/actions.js.map +2 -2
  65. package/dist/modules/messages/lib/actions.js +24 -4
  66. package/dist/modules/messages/lib/actions.js.map +2 -2
  67. package/dist/modules/sales/api/documents/factory.js +49 -36
  68. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  69. package/package.json +9 -10
  70. package/src/modules/api_keys/api/keys/route.ts +9 -0
  71. package/src/modules/audit_logs/services/accessLogService.ts +20 -0
  72. package/src/modules/audit_logs/services/actionLogService.ts +13 -5
  73. package/src/modules/auth/api/roles/acl/route.ts +32 -46
  74. package/src/modules/auth/api/users/route.ts +48 -33
  75. package/src/modules/auth/lib/grantChecks.ts +234 -0
  76. package/src/modules/configs/cli.ts +11 -0
  77. package/src/modules/configs/lib/touchGeneratedBarrels.ts +61 -0
  78. package/src/modules/customers/api/activities/route.ts +1 -76
  79. package/src/modules/customers/api/interactions/counts/route.ts +2 -1
  80. package/src/modules/customers/api/interactions/route.ts +28 -1
  81. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +13 -3
  82. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +14 -2
  83. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -3
  84. package/src/modules/customers/components/detail/ActivitiesCard.tsx +92 -5
  85. package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +38 -6
  86. package/src/modules/customers/components/detail/ActivitiesSection.tsx +37 -3
  87. package/src/modules/customers/components/detail/ActivityCard.tsx +79 -14
  88. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +102 -33
  89. package/src/modules/customers/components/detail/ActivityLogTab.tsx +7 -1
  90. package/src/modules/customers/components/detail/ActivityTimeline.tsx +39 -5
  91. package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +29 -7
  92. package/src/modules/customers/components/detail/ActivityTypeSelector.tsx +3 -2
  93. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +96 -13
  94. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +50 -4
  95. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +21 -5
  96. package/src/modules/customers/data/validators.ts +85 -2
  97. package/src/modules/customers/i18n/de.json +11 -0
  98. package/src/modules/customers/i18n/en.json +11 -0
  99. package/src/modules/customers/i18n/es.json +11 -0
  100. package/src/modules/customers/i18n/pl.json +11 -0
  101. package/src/modules/customers/lib/legacyActivityBridge.ts +106 -0
  102. package/src/modules/integrations/data/validators.ts +8 -6
  103. package/src/modules/integrations/lib/credentials-service.ts +15 -1
  104. package/src/modules/messages/commands/actions.ts +28 -13
  105. package/src/modules/messages/lib/actions.ts +34 -3
  106. package/src/modules/sales/api/documents/factory.ts +55 -38
@@ -8,6 +8,7 @@ import {
8
8
  type AccessLogListQuery,
9
9
  } from '@open-mercato/core/modules/audit_logs/data/validators'
10
10
  import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
11
+ import { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
11
12
  import { E } from '#generated/entities.ids.generated'
12
13
 
13
14
  const CORE_RESOURCE_KINDS = new Set<string>(['auth.user', 'auth.role'])
@@ -190,6 +191,25 @@ export class AccessLogService {
190
191
  },
191
192
  )
192
193
 
194
+ // Encrypted jsonb columns (`fields_json`, `context_json`) come back as raw
195
+ // JSON strings from the encryption subscriber after issue #1810 follow-up
196
+ // (entity-field decryption no longer auto-parses). Restore the structured
197
+ // shape on read so API consumers see typed objects/arrays.
198
+ for (const item of items) {
199
+ const rawFieldsJson = (item as { fieldsJson?: unknown }).fieldsJson
200
+ if (typeof rawFieldsJson === 'string') {
201
+ const parsed = parseDecryptedFieldValue(rawFieldsJson)
202
+ item.fieldsJson = Array.isArray(parsed) ? (parsed as string[]) : null
203
+ }
204
+ const rawContextJson = (item as { contextJson?: unknown }).contextJson
205
+ if (typeof rawContextJson === 'string') {
206
+ const parsed = parseDecryptedFieldValue(rawContextJson)
207
+ item.contextJson = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
208
+ ? (parsed as Record<string, unknown>)
209
+ : null
210
+ }
211
+ }
212
+
193
213
  const totalPages = Math.max(1, Math.ceil((total || 0) / (pageSize || 1)))
194
214
  return { items, total, page, pageSize, totalPages }
195
215
  }
@@ -151,15 +151,23 @@ export class ActionLogService {
151
151
  ...decrypted,
152
152
  } as Record<string, unknown>
153
153
 
154
- merged.changesJson = deepDecrypt(merged.changesJson ?? merged.changes_json ?? entry.changesJson ?? entry.changes_json)
154
+ // Audit log jsonb columns are encrypted as JSON-stringified payloads. After the
155
+ // service-level `decryptEntityPayload` call (which now returns raw strings —
156
+ // see issue #1810 follow-up), reattach the structured shape via
157
+ // `parseDecryptedFieldValue` before running the recursive `deepDecrypt` walk
158
+ // so nested encrypted strings inside payload objects/arrays are handled.
159
+ const restoreJson = (value: unknown): unknown =>
160
+ typeof value === 'string' ? parseDecryptedFieldValue(value) : value
161
+
162
+ merged.changesJson = deepDecrypt(restoreJson(merged.changesJson ?? merged.changes_json ?? entry.changesJson ?? entry.changes_json))
155
163
  merged.changes_json = merged.changesJson
156
- merged.snapshotBefore = deepDecrypt(merged.snapshotBefore ?? merged.snapshot_before ?? entry.snapshotBefore ?? entry.snapshot_before)
164
+ merged.snapshotBefore = deepDecrypt(restoreJson(merged.snapshotBefore ?? merged.snapshot_before ?? entry.snapshotBefore ?? entry.snapshot_before))
157
165
  merged.snapshot_before = merged.snapshotBefore
158
- merged.snapshotAfter = deepDecrypt(merged.snapshotAfter ?? merged.snapshot_after ?? entry.snapshotAfter ?? entry.snapshot_after)
166
+ merged.snapshotAfter = deepDecrypt(restoreJson(merged.snapshotAfter ?? merged.snapshot_after ?? entry.snapshotAfter ?? entry.snapshot_after))
159
167
  merged.snapshot_after = merged.snapshotAfter
160
- merged.commandPayload = deepDecrypt(merged.commandPayload ?? merged.command_payload ?? entry.commandPayload ?? entry.command_payload)
168
+ merged.commandPayload = deepDecrypt(restoreJson(merged.commandPayload ?? merged.command_payload ?? entry.commandPayload ?? entry.command_payload))
161
169
  merged.command_payload = merged.commandPayload
162
- merged.contextJson = deepDecrypt(merged.contextJson ?? merged.context_json ?? entry.contextJson ?? entry.context_json)
170
+ merged.contextJson = deepDecrypt(restoreJson(merged.contextJson ?? merged.context_json ?? entry.contextJson ?? entry.context_json))
163
171
  merged.context_json = merged.contextJson
164
172
 
165
173
  return merged as T
@@ -4,11 +4,12 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'
7
- import { forbidden } from '@open-mercato/shared/lib/crud/errors'
7
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
8
  import { RoleAcl, Role } from '@open-mercato/core/modules/auth/data/entities'
9
9
  import type { EntityManager } from '@mikro-orm/postgresql'
10
10
  import { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
11
11
  import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
12
+ import { assertActorCanGrantAcl, normalizeGrantFeatureList } from '@open-mercato/core/modules/auth/lib/grantChecks'
12
13
 
13
14
  type TaggableCache = { deleteByTags?: (tags: string[]) => Promise<void> | void }
14
15
 
@@ -132,12 +133,6 @@ export async function PUT(req: Request) {
132
133
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
133
134
  }
134
135
 
135
- const actorAcl = auth.sub
136
- ? await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
137
- : null
138
- const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin
139
-
140
- const requestedFeatures = normalizeFeatureList(parsed.data.features)
141
136
  let acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId })
142
137
  if (!acl) {
143
138
  acl = em.create(RoleAcl, {
@@ -149,27 +144,35 @@ export async function PUT(req: Request) {
149
144
  }
150
145
 
151
146
  const existingIsSuperAdmin = !!acl.isSuperAdmin
147
+ const existingFeatures = normalizeGrantFeatureList(acl.featuresJson)
148
+ const existingOrganizations = normalizeOrganizations(acl.organizationsJson)
152
149
  const requestedIsSuperAdmin = parsed.data.isSuperAdmin ?? existingIsSuperAdmin
153
- let effectiveIsSuperAdmin = requestedIsSuperAdmin
154
-
155
- if (!actorIsSuperAdmin) {
156
- if (requestedIsSuperAdmin && !existingIsSuperAdmin) {
157
- throw forbidden('Only super administrators can mark a role as super admin.')
158
- }
159
- if (existingIsSuperAdmin && requestedIsSuperAdmin === false) {
160
- effectiveIsSuperAdmin = false
161
- } else {
162
- effectiveIsSuperAdmin = existingIsSuperAdmin
163
- }
150
+ const requestedFeatures = parsed.data.features === undefined
151
+ ? existingFeatures
152
+ : normalizeGrantFeatureList(parsed.data.features)
153
+ const requestedOrganizations = parsed.data.organizations === undefined
154
+ ? existingOrganizations
155
+ : normalizeOrganizations(parsed.data.organizations)
156
+
157
+ try {
158
+ await assertActorCanGrantAcl({
159
+ em,
160
+ rbacService,
161
+ actorUserId: auth.sub,
162
+ tenantId: targetTenantId,
163
+ organizationId: auth.orgId ?? null,
164
+ isSuperAdmin: requestedIsSuperAdmin,
165
+ features: requestedFeatures,
166
+ organizations: requestedOrganizations,
167
+ })
168
+ } catch (err) {
169
+ if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
170
+ throw err
164
171
  }
165
172
 
166
- const effectiveFeatures = actorIsSuperAdmin
167
- ? requestedFeatures
168
- : sanitizeTenantFeatures(requestedFeatures)
169
-
170
- if (parsed.data.organizations !== undefined) acl.organizationsJson = parsed.data.organizations
171
- acl.isSuperAdmin = effectiveIsSuperAdmin
172
- acl.featuresJson = effectiveFeatures
173
+ acl.organizationsJson = requestedOrganizations
174
+ acl.isSuperAdmin = requestedIsSuperAdmin
175
+ acl.featuresJson = requestedFeatures
173
176
  await em.persist(acl).flush()
174
177
 
175
178
  // Invalidate cache for all users in this tenant since role ACL changed
@@ -184,30 +187,13 @@ export async function PUT(req: Request) {
184
187
 
185
188
  return NextResponse.json({
186
189
  ok: true,
187
- sanitized: !actorIsSuperAdmin && (effectiveFeatures.length !== requestedFeatures.length || effectiveIsSuperAdmin !== requestedIsSuperAdmin),
190
+ sanitized: false,
188
191
  })
189
192
  }
190
193
 
191
- function normalizeFeatureList(features: unknown): string[] {
192
- if (!Array.isArray(features)) return []
193
- const dedup = new Set<string>()
194
- for (const value of features) {
195
- if (typeof value !== 'string') continue
196
- const trimmed = value.trim()
197
- if (!trimmed) continue
198
- dedup.add(trimmed)
199
- }
200
- return Array.from(dedup)
201
- }
202
-
203
- function sanitizeTenantFeatures(features: string[]): string[] {
204
- return features.filter((feature) => !isTenantRestrictedFeature(feature))
205
- }
206
-
207
- function isTenantRestrictedFeature(feature: string): boolean {
208
- if (feature === '*' || feature === 'directory.*') return true
209
- if (feature.startsWith('directory.tenants')) return true
210
- return false
194
+ function normalizeOrganizations(organizations: unknown): string[] | null {
195
+ if (!Array.isArray(organizations)) return null
196
+ return normalizeGrantFeatureList(organizations)
211
197
  }
212
198
 
213
199
  export const openApi: OpenApiRouteDoc = {
@@ -3,17 +3,18 @@ import { NextResponse } from 'next/server'
3
3
  import { z } from 'zod'
4
4
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
5
5
  import { logCrudAccess, makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
6
- import { forbidden } from '@open-mercato/shared/lib/crud/errors'
6
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
7
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
8
8
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
9
9
  import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
10
- import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
10
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
11
11
  import { Organization, Tenant } from '@open-mercato/core/modules/directory/data/entities'
12
12
  import { E } from '#generated/entities.ids.generated'
13
13
  import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
14
14
  import type { EntityManager } from '@mikro-orm/postgresql'
15
15
  import { userCrudEvents, userCrudIndexer } from '@open-mercato/core/modules/auth/commands/users'
16
- import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
16
+ import { assertActorCanGrantRoleTokens } from '@open-mercato/core/modules/auth/lib/grantChecks'
17
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
17
18
  import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
18
19
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
19
20
  import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
@@ -102,7 +103,7 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
102
103
  schema: rawBodySchema,
103
104
  mapInput: async ({ parsed, ctx }) => {
104
105
  if (ctx.request) {
105
- await assertCanAssignRoles(ctx.request, parsed.roles)
106
+ await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
106
107
  }
107
108
  return parsed
108
109
  },
@@ -117,7 +118,7 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
117
118
  schema: rawBodySchema,
118
119
  mapInput: async ({ parsed, ctx }) => {
119
120
  if (ctx.request) {
120
- await assertCanAssignRoles(ctx.request, parsed.roles)
121
+ await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
121
122
  }
122
123
  return parsed
123
124
  },
@@ -371,14 +372,10 @@ export async function GET(req: Request) {
371
372
  }
372
373
 
373
374
  export const POST = async (req: Request) => {
374
- const body = await req.clone().json().catch(() => ({}))
375
- await assertCanAssignRoles(req, body?.roles)
376
375
  return crud.POST(req)
377
376
  }
378
377
 
379
378
  export const PUT = async (req: Request) => {
380
- const body = await req.clone().json().catch(() => ({}))
381
- await assertCanAssignRoles(req, body?.roles)
382
379
  return crud.PUT(req)
383
380
  }
384
381
 
@@ -414,35 +411,53 @@ async function findUserIdsBySearchTokens(
414
411
  .filter((id): id is string => typeof id === 'string' && id.length > 0)
415
412
  }
416
413
 
417
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
418
-
419
- async function assertCanAssignRoles(req: Request, roles: unknown) {
414
+ async function assertCanAssignRoles(req: Request, roles: unknown, payload: Record<string, unknown>) {
420
415
  if (!Array.isArray(roles)) return
421
- const values = roles
422
- .map((role) => (typeof role === 'string' ? role.trim() : null))
423
- .filter((role): role is string => !!role)
424
- if (!values.length) return
416
+ const auth = await getAuthFromRequest(req)
417
+ if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
418
+ const container = await createRequestContainer()
419
+ const em = container.resolve('em') as EntityManager
420
+ const tenantId = await resolveTargetTenantIdForRoleGrant(em, payload, auth.tenantId ?? null)
421
+ await assertActorCanGrantRoleTokens({
422
+ em,
423
+ rbacService: container.resolve('rbacService') as RbacService,
424
+ actorUserId: auth.sub,
425
+ tenantId,
426
+ organizationId: auth.orgId ?? null,
427
+ roleTokens: roles,
428
+ })
429
+ }
425
430
 
426
- let hasSuperAdmin = values.some((v) => v.toLowerCase() === 'superadmin')
427
- if (!hasSuperAdmin) {
428
- const uuids = values.filter((v) => UUID_RE.test(v))
429
- if (uuids.length) {
430
- const container = await createRequestContainer()
431
- const em = container.resolve('em') as EntityManager
432
- const matched = await em.find(Role, { id: { $in: uuids as any } })
433
- hasSuperAdmin = matched.some((r) => String(r.name).toLowerCase() === 'superadmin')
434
- }
431
+ async function resolveTargetTenantIdForRoleGrant(
432
+ em: EntityManager,
433
+ payload: Record<string, unknown>,
434
+ fallbackTenantId: string | null,
435
+ ): Promise<string | null> {
436
+ const organizationId = typeof payload.organizationId === 'string' ? payload.organizationId : null
437
+ if (organizationId) {
438
+ const organization = await findOneWithDecryption(
439
+ em,
440
+ Organization,
441
+ { id: organizationId },
442
+ { populate: ['tenant'] },
443
+ { tenantId: null, organizationId },
444
+ )
445
+ return organization?.tenant?.id ? String(organization.tenant.id) : fallbackTenantId
435
446
  }
436
- if (!hasSuperAdmin) return
437
447
 
438
- const auth = await getAuthFromRequest(req)
439
- if (!auth) throw new Error('Unauthorized')
440
- const container = await createRequestContainer()
441
- const rbac = container.resolve('rbacService') as RbacService
442
- const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
443
- if (!acl?.isSuperAdmin) {
444
- throw forbidden('Only super administrators can assign the superadmin role.')
448
+ const userId = typeof payload.id === 'string' ? payload.id : null
449
+ if (userId) {
450
+ const user = await findOneWithDecryption(
451
+ em,
452
+ User,
453
+ { id: userId, deletedAt: null },
454
+ {},
455
+ { tenantId: null, organizationId: null },
456
+ )
457
+ return user?.tenantId ? String(user.tenantId) : fallbackTenantId
445
458
  }
459
+
460
+ return fallbackTenantId
446
461
  }
447
462
 
448
463
  export const openApi: OpenApiRouteDoc = {
@@ -0,0 +1,234 @@
1
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
+ import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
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'
6
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+
8
+ type ActorAcl = {
9
+ isSuperAdmin: boolean
10
+ features: string[]
11
+ organizations: string[] | null
12
+ }
13
+
14
+ type GrantCheckContext = {
15
+ em: EntityManager
16
+ rbacService: RbacService
17
+ actorUserId: string | null | undefined
18
+ tenantId: string | null | undefined
19
+ organizationId?: string | null | undefined
20
+ }
21
+
22
+ type RoleGrantCheckInput = GrantCheckContext & {
23
+ roles: Role[]
24
+ }
25
+
26
+ type RoleTokenGrantCheckInput = GrantCheckContext & {
27
+ roleTokens: unknown
28
+ }
29
+
30
+ type FeatureGrantCheckInput = GrantCheckContext & {
31
+ features: unknown
32
+ isSuperAdmin?: boolean
33
+ organizations?: string[] | null
34
+ }
35
+
36
+ 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
+
38
+ export async function assertActorCanGrantRoleTokens(input: RoleTokenGrantCheckInput): Promise<Role[]> {
39
+ const tokens = normalizeStringList(input.roleTokens)
40
+ if (!tokens.length) return []
41
+
42
+ const tenantId = normalizeNullableString(input.tenantId)
43
+ const roles = await resolveRolesForGrant(input.em, tokens, tenantId)
44
+ await assertActorCanGrantRoles({ ...input, tenantId, roles })
45
+ return roles
46
+ }
47
+
48
+ export async function assertActorCanGrantRoles(input: RoleGrantCheckInput): Promise<void> {
49
+ if (!input.roles.length) return
50
+
51
+ const tenantId = normalizeNullableString(input.tenantId)
52
+ const actorAcl = await loadActorAcl({ ...input, tenantId })
53
+ if (actorAcl.isSuperAdmin) return
54
+
55
+ if (!tenantId) {
56
+ throw forbidden('Tenant context is required to grant roles.')
57
+ }
58
+
59
+ for (const role of input.roles) {
60
+ const roleTenantId = normalizeNullableString(role.tenantId)
61
+ if (roleTenantId !== tenantId) {
62
+ throw forbidden('Cannot grant a role outside the target tenant.')
63
+ }
64
+
65
+ const acl = await findOneWithDecryption(
66
+ input.em,
67
+ RoleAcl,
68
+ { role, tenantId } as FilterQuery<RoleAcl>,
69
+ {},
70
+ { tenantId, organizationId: null },
71
+ )
72
+ if (!acl) continue
73
+
74
+ assertActorCanGrantAclSnapshot(actorAcl, {
75
+ isSuperAdmin: !!acl.isSuperAdmin,
76
+ features: normalizeStringList(acl.featuresJson),
77
+ organizations: normalizeOrganizationList(acl.organizationsJson),
78
+ })
79
+ }
80
+ }
81
+
82
+ export async function assertActorCanGrantAcl(input: FeatureGrantCheckInput): Promise<void> {
83
+ const actorAcl = await loadActorAcl(input)
84
+ if (actorAcl.isSuperAdmin) return
85
+
86
+ const tenantId = normalizeNullableString(input.tenantId)
87
+ if (!tenantId) {
88
+ throw forbidden('Tenant context is required to grant ACL features.')
89
+ }
90
+
91
+ assertActorCanGrantAclSnapshot(actorAcl, {
92
+ isSuperAdmin: !!input.isSuperAdmin,
93
+ features: normalizeStringList(input.features),
94
+ organizations: input.organizations === undefined ? undefined : normalizeOrganizationList(input.organizations),
95
+ })
96
+ }
97
+
98
+ export function normalizeGrantFeatureList(features: unknown): string[] {
99
+ return normalizeStringList(features)
100
+ }
101
+
102
+ async function loadActorAcl(input: GrantCheckContext): Promise<ActorAcl> {
103
+ const actorUserId = normalizeNullableString(input.actorUserId)
104
+ if (!actorUserId) throw forbidden('Not authorized to grant ACL privileges.')
105
+
106
+ const acl = await input.rbacService.loadAcl(actorUserId, {
107
+ tenantId: normalizeNullableString(input.tenantId),
108
+ organizationId: normalizeNullableString(input.organizationId),
109
+ })
110
+
111
+ return {
112
+ isSuperAdmin: !!acl?.isSuperAdmin,
113
+ features: normalizeStringList(acl?.features),
114
+ organizations: normalizeOrganizationList(acl?.organizations),
115
+ }
116
+ }
117
+
118
+ async function resolveRolesForGrant(
119
+ em: EntityManager,
120
+ roleTokens: string[],
121
+ tenantId: string | null,
122
+ ): Promise<Role[]> {
123
+ const roles: Role[] = []
124
+ const missingRoles: string[] = []
125
+
126
+ for (const token of roleTokens) {
127
+ const role = await resolveRoleForGrant(em, token, tenantId)
128
+ if (!role) {
129
+ missingRoles.push(token)
130
+ } else {
131
+ roles.push(role)
132
+ }
133
+ }
134
+
135
+ if (missingRoles.length) {
136
+ const labels = missingRoles.map((role) => `"${role}"`).join(', ')
137
+ throw new CrudHttpError(400, { error: `Role(s) not found: ${labels}` })
138
+ }
139
+
140
+ return roles
141
+ }
142
+
143
+ async function resolveRoleForGrant(
144
+ em: EntityManager,
145
+ token: string,
146
+ tenantId: string | null,
147
+ ): Promise<Role | null> {
148
+ const where: Record<string, unknown> = UUID_RE.test(token)
149
+ ? { id: token, deletedAt: null }
150
+ : { name: token, deletedAt: null }
151
+ if (tenantId) where.tenantId = tenantId
152
+ return findOneWithDecryption(
153
+ em,
154
+ Role,
155
+ where as FilterQuery<Role>,
156
+ {},
157
+ { tenantId, organizationId: null },
158
+ )
159
+ }
160
+
161
+ function assertActorCanGrantAclSnapshot(
162
+ actorAcl: ActorAcl,
163
+ requested: {
164
+ isSuperAdmin: boolean
165
+ features: string[]
166
+ organizations?: string[] | null
167
+ },
168
+ ): void {
169
+ if (requested.isSuperAdmin) {
170
+ throw forbidden('Only super administrators can grant super admin access.')
171
+ }
172
+
173
+ const actorGrantableFeatures = actorAcl.features.filter((grant) => grant !== '*')
174
+ for (const feature of requested.features) {
175
+ if (feature === '*') {
176
+ throw forbidden('Only super administrators can grant global wildcard access.')
177
+ }
178
+ if (isWildcardFeature(feature)) {
179
+ if (!hasFeature(actorGrantableFeatures, feature)) {
180
+ throw forbidden(`Cannot grant feature wildcard ${feature}.`)
181
+ }
182
+ continue
183
+ }
184
+ if (!hasFeature(actorGrantableFeatures, feature)) {
185
+ throw forbidden(`Cannot grant feature ${feature}.`)
186
+ }
187
+ }
188
+
189
+ if (requested.organizations !== undefined) {
190
+ assertActorCanGrantOrganizations(actorAcl.organizations, requested.organizations)
191
+ }
192
+ }
193
+
194
+ function assertActorCanGrantOrganizations(
195
+ actorOrganizations: string[] | null,
196
+ requestedOrganizations: string[] | null,
197
+ ): void {
198
+ if (actorOrganizations === null || actorOrganizations.includes('__all__')) return
199
+
200
+ if (requestedOrganizations === null || requestedOrganizations.includes('__all__')) {
201
+ throw forbidden('Cannot grant unrestricted organization access.')
202
+ }
203
+
204
+ for (const organizationId of requestedOrganizations) {
205
+ if (!actorOrganizations.includes(organizationId)) {
206
+ throw forbidden('Cannot grant organization access outside actor scope.')
207
+ }
208
+ }
209
+ }
210
+
211
+ function normalizeStringList(values: unknown): string[] {
212
+ if (!Array.isArray(values)) return []
213
+ const dedup = new Set<string>()
214
+ for (const value of values) {
215
+ if (typeof value !== 'string') continue
216
+ const trimmed = value.trim()
217
+ if (!trimmed) continue
218
+ dedup.add(trimmed)
219
+ }
220
+ return Array.from(dedup)
221
+ }
222
+
223
+ function normalizeOrganizationList(values: unknown): string[] | null {
224
+ if (values === null || values === undefined) return null
225
+ return normalizeStringList(values)
226
+ }
227
+
228
+ function normalizeNullableString(value: unknown): string | null {
229
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
230
+ }
231
+
232
+ function isWildcardFeature(feature: string): boolean {
233
+ return feature.endsWith('.*')
234
+ }
@@ -12,6 +12,7 @@ import {
12
12
  previewCachePurge,
13
13
  type CachePurgeRequest,
14
14
  } from './lib/cache-cli'
15
+ import { touchGeneratedBarrels } from './lib/touchGeneratedBarrels'
15
16
 
16
17
  type ParsedArgs = Record<string, string | boolean>
17
18
 
@@ -256,6 +257,16 @@ async function runStructuralCachePurge(args: ParsedArgs) {
256
257
  pattern: 'nav:*',
257
258
  }
258
259
  await runCachePurge(nextArgs)
260
+ const quiet = flagEnabled(args, 'quiet')
261
+ try {
262
+ touchGeneratedBarrels({ quiet })
263
+ } catch (err) {
264
+ if (!quiet) {
265
+ console.warn(
266
+ `[structural] failed to touch generated barrels: ${(err as Error).message ?? err}`,
267
+ )
268
+ }
269
+ }
259
270
  }
260
271
 
261
272
  function envDisablesAutoIndexing(): boolean {
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ const GENERATED_DIR_RELATIVE = path.join('.mercato', 'generated')
5
+ const TOUCHABLE_PATTERN = /\.generated(?:\.[a-z0-9]+)?(?:\.ts|\.checksum)$/i
6
+ const MAX_PARENT_WALK = 5
7
+
8
+ export type TouchGeneratedBarrelsOptions = {
9
+ cwd?: string
10
+ quiet?: boolean
11
+ log?: (message: string) => void
12
+ }
13
+
14
+ export type TouchGeneratedBarrelsResult = {
15
+ generatedDir: string | null
16
+ files: string[]
17
+ }
18
+
19
+ export function findGeneratedDir(startDir: string): string | null {
20
+ let current = path.resolve(startDir)
21
+ for (let depth = 0; depth <= MAX_PARENT_WALK; depth += 1) {
22
+ const candidate = path.join(current, GENERATED_DIR_RELATIVE)
23
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
24
+ return candidate
25
+ }
26
+ const parent = path.dirname(current)
27
+ if (parent === current) break
28
+ current = parent
29
+ }
30
+ return null
31
+ }
32
+
33
+ export function touchGeneratedBarrels(
34
+ options: TouchGeneratedBarrelsOptions = {},
35
+ ): TouchGeneratedBarrelsResult {
36
+ const cwd = options.cwd ?? process.cwd()
37
+ const log = options.log ?? ((message: string) => console.log(message))
38
+ const quiet = options.quiet === true
39
+
40
+ const generatedDir = findGeneratedDir(cwd)
41
+ if (!generatedDir) {
42
+ return { generatedDir: null, files: [] }
43
+ }
44
+
45
+ const touched: string[] = []
46
+ const entries = fs.readdirSync(generatedDir, { withFileTypes: true })
47
+ for (const entry of entries) {
48
+ if (!entry.isFile()) continue
49
+ if (!TOUCHABLE_PATTERN.test(entry.name)) continue
50
+ const filePath = path.join(generatedDir, entry.name)
51
+ const contents = fs.readFileSync(filePath)
52
+ fs.writeFileSync(filePath, contents)
53
+ touched.push(filePath)
54
+ }
55
+
56
+ if (!quiet && touched.length > 0) {
57
+ log(`🔁 [structural] touched ${touched.length} generated barrel(s) → ${generatedDir}`)
58
+ }
59
+
60
+ return { generatedDir, files: touched }
61
+ }