@open-mercato/core 0.6.8-develop.7023.1.a1960eb208 → 0.6.8-develop.7026.1.23445b3f3e

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 (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/roles/acl/route.js +28 -33
  3. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  4. package/dist/modules/auth/api/users/acl/route.js +96 -51
  5. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  6. package/dist/modules/auth/commands/acl.js +254 -0
  7. package/dist/modules/auth/commands/acl.js.map +7 -0
  8. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  9. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +9 -1
  10. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  11. package/dist/modules/inbox_ops/lib/htmlToPlainText.js +1 -12
  12. package/dist/modules/inbox_ops/lib/htmlToPlainText.js.map +2 -2
  13. package/dist/modules/messages/api/[id]/route.js +3 -0
  14. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  15. package/dist/modules/messages/api/openapi.js +3 -0
  16. package/dist/modules/messages/api/openapi.js.map +2 -2
  17. package/dist/modules/messages/components/MessagesInboxPageClient.js +3 -13
  18. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  19. package/dist/modules/messages/components/inboxFilters.js +15 -0
  20. package/dist/modules/messages/components/inboxFilters.js.map +2 -2
  21. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js +11 -1
  22. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js.map +2 -2
  23. package/dist/modules/messages/components/message-detail/panels/MessageHeader.js +3 -1
  24. package/dist/modules/messages/components/message-detail/panels/MessageHeader.js.map +2 -2
  25. package/dist/modules/messages/components/message-detail/panels/thread-panel.js +2 -1
  26. package/dist/modules/messages/components/message-detail/panels/thread-panel.js.map +2 -2
  27. package/dist/modules/messages/components/messageListLabels.js +14 -2
  28. package/dist/modules/messages/components/messageListLabels.js.map +2 -2
  29. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +64 -14
  30. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  31. package/package.json +7 -9
  32. package/src/modules/auth/api/roles/acl/route.ts +42 -39
  33. package/src/modules/auth/api/users/acl/route.ts +177 -53
  34. package/src/modules/auth/commands/acl.ts +495 -0
  35. package/src/modules/auth/i18n/de.json +2 -0
  36. package/src/modules/auth/i18n/en.json +2 -0
  37. package/src/modules/auth/i18n/es.json +2 -0
  38. package/src/modules/auth/i18n/ko.json +2 -0
  39. package/src/modules/auth/i18n/pl.json +2 -0
  40. package/src/modules/auth/lib/grantChecks.ts +6 -0
  41. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +33 -1
  42. package/src/modules/inbox_ops/lib/htmlToPlainText.ts +1 -17
  43. package/src/modules/messages/api/[id]/route.ts +3 -0
  44. package/src/modules/messages/api/openapi.ts +3 -0
  45. package/src/modules/messages/components/MessagesInboxPageClient.tsx +5 -16
  46. package/src/modules/messages/components/inboxFilters.ts +36 -0
  47. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.ts +14 -1
  48. package/src/modules/messages/components/message-detail/panels/MessageHeader.tsx +3 -1
  49. package/src/modules/messages/components/message-detail/panels/thread-panel.tsx +2 -1
  50. package/src/modules/messages/components/message-detail/types.ts +3 -0
  51. package/src/modules/messages/components/messageListLabels.ts +52 -3
  52. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +101 -14
  53. package/src/modules/sales/i18n/de.json +2 -0
  54. package/src/modules/sales/i18n/en.json +2 -0
  55. package/src/modules/sales/i18n/es.json +2 -0
  56. package/src/modules/sales/i18n/ko.json +2 -0
  57. package/src/modules/sales/i18n/pl.json +2 -0
@@ -0,0 +1,495 @@
1
+ // =============================================================================
2
+ // Auth ACL Commands — Undo Policy
3
+ // =============================================================================
4
+ //
5
+ // Neither command in this file defines `undo` or `redo`, so the command bus
6
+ // never mints an undo token for them: `CommandBus.isUndoable()` requires a
7
+ // handler-provided `undo`. The explicit `isUndoable: false` below is executable
8
+ // documentation of that intent, not the mechanism — the guarantee comes from
9
+ // the absence of the handlers, and the unit tests pin both.
10
+ //
11
+ // The policy is deliberate even though an ACL grant is trivially reversible.
12
+ // The undo and redo endpoints are gated on `audit_logs.undo_self` /
13
+ // `audit_logs.undo_tenant` (and their redo counterparts), not on
14
+ // `auth.acl.manage`. `audit_logs.undo_self` is a default `employee` grant and
15
+ // `audit_logs.undo_tenant` reaches every `admin` through `audit_logs.*`, so an
16
+ // undoable ACL command would let a caller revert or replay someone else's
17
+ // permission change without ever holding the feature that authorizes editing
18
+ // permissions.
19
+ //
20
+ // Reversal stays available to the callers who are actually authorized for it:
21
+ // re-submitting the ACL form, gated on `auth.acl.manage` and guarded by
22
+ // `assertActorCanGrantAcl`. The audit log still captures the full before/after
23
+ // via `buildLog`, so any such correction is itself fully traceable.
24
+ // =============================================================================
25
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
26
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
27
+ import { snapshotsEqual } from '@open-mercato/shared/lib/commands/helpers'
28
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
29
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
30
+ import { createLogger } from '@open-mercato/shared/lib/logger'
31
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
32
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
33
+ import { Role, RoleAcl, User, UserAcl } from '@open-mercato/core/modules/auth/data/entities'
34
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
35
+
36
+ const logger = createLogger('auth').child({ component: 'acl-commands' })
37
+
38
+ type TaggableCache = { deleteByTags?: (tags: string[]) => Promise<void> | void }
39
+
40
+ /** Structural view of the fields both `RoleAcl` and `UserAcl` expose. */
41
+ type AclRecord = {
42
+ isSuperAdmin: boolean
43
+ featuresJson?: string[] | null
44
+ organizationsJson?: string[] | null
45
+ }
46
+
47
+ /**
48
+ * Audit snapshot of a single ACL row.
49
+ *
50
+ * `features` and `organizations` are grant *sets*, but `features_json` /
51
+ * `organizations_json` preserve the client's insertion order. The command bus
52
+ * derives `changes` from these snapshots with a deep equality check that is
53
+ * order-sensitive for arrays, so an unsorted snapshot would report a spurious
54
+ * `features` change every time an admin re-saves an unmodified form. Sorting
55
+ * here makes the derived diff reflect real grant changes only.
56
+ *
57
+ * `organizations: null` (visible in every organization) is deliberately
58
+ * distinct from `[]` (visible in none).
59
+ */
60
+ type AclSnapshot = {
61
+ isSuperAdmin: boolean
62
+ features: string[]
63
+ organizations: string[] | null
64
+ }
65
+
66
+ const EMPTY_ACL_SNAPSHOT: AclSnapshot = {
67
+ isSuperAdmin: false,
68
+ features: [],
69
+ organizations: null,
70
+ }
71
+
72
+ /**
73
+ * Identity of the role or user an entry is about.
74
+ *
75
+ * `action_logs` keeps only `resource_id`, so an entry degrades to a bare UUID
76
+ * the moment the role or account is deleted — exactly when the trail matters
77
+ * most. This rides in `context` rather than in the snapshots because the
78
+ * command bus derives `changes` from the two snapshots with a deep comparison:
79
+ * a target renamed between two ACL edits would otherwise be reported as a
80
+ * permission change, and the no-op guard below would stop recognising an
81
+ * unchanged ACL.
82
+ */
83
+ type AclTarget =
84
+ | { kind: 'user'; id: string; email: string | null; name: string | null }
85
+ | { kind: 'role'; id: string; name: string | null }
86
+
87
+ /**
88
+ * What the caller asked for when the route trimmed the request instead of
89
+ * refusing it.
90
+ *
91
+ * `sanitizeTenantFeatures` drops restricted grants silently, so such a request
92
+ * leaves `before` and `after` identical and is indistinguishable from a no-op —
93
+ * which `buildLog` skips. Recording the original request both keeps the attempt
94
+ * visible and exempts the entry from that guard.
95
+ *
96
+ * Features are the only axis carried, because they are the only one the route
97
+ * can trim: a super-admin request is honoured or refused outright, and
98
+ * organizations are never sanitized. Recording either would put a value next to
99
+ * the snapshots that always matches them, inviting a reader to diff the two and
100
+ * conclude a grant was refused.
101
+ */
102
+ type SanitizedAclRequest = {
103
+ features: string[]
104
+ }
105
+
106
+ /** How the change reads at a glance, without diffing two grant lists. */
107
+ type AclChangeEffect = 'granted' | 'changed' | 'revoked'
108
+
109
+ /**
110
+ * Codepoint ordering rather than `localeCompare`: these snapshots are persisted
111
+ * audit records, so the ordering must not shift with the runtime's default
112
+ * locale. Feature and organization ids are opaque ASCII identifiers that are
113
+ * never shown in this order to a user.
114
+ *
115
+ * The default comparator would coerce and compare the same way today, but
116
+ * omitting one is banned repo-wide (`explicit-sort-comparators.test.ts`,
117
+ * #3620): the ordering must be stated rather than inherited, so it cannot
118
+ * silently change meaning if a call site ever drifts to non-strings. This is
119
+ * the canonical-key form that audit prescribes.
120
+ */
121
+ function compareIdentifiers(left: string, right: string): number {
122
+ if (left === right) return 0
123
+ return left < right ? -1 : 1
124
+ }
125
+
126
+ function toSortedList(values: unknown): string[] {
127
+ if (!Array.isArray(values)) return []
128
+ return [...values].filter((value): value is string => typeof value === 'string').sort(compareIdentifiers)
129
+ }
130
+
131
+ function captureAclSnapshot(acl: AclRecord | null | undefined): AclSnapshot {
132
+ if (!acl) return { ...EMPTY_ACL_SNAPSHOT }
133
+ return {
134
+ isSuperAdmin: Boolean(acl.isSuperAdmin),
135
+ features: toSortedList(acl.featuresJson),
136
+ organizations: Array.isArray(acl.organizationsJson) ? toSortedList(acl.organizationsJson) : null,
137
+ }
138
+ }
139
+
140
+ function holdsGrants(snapshot: AclSnapshot): boolean {
141
+ return snapshot.isSuperAdmin || snapshot.features.length > 0
142
+ }
143
+
144
+ function resolveEffect(before: AclSnapshot, after: AclSnapshot): AclChangeEffect {
145
+ if (!holdsGrants(before) && holdsGrants(after)) return 'granted'
146
+ if (holdsGrants(before) && !holdsGrants(after)) return 'revoked'
147
+ return 'changed'
148
+ }
149
+
150
+ function resolveEm(ctx: CommandRuntimeContext): EntityManager {
151
+ return ctx.container.resolve('em') as EntityManager
152
+ }
153
+
154
+ /**
155
+ * The log row's organization must belong to the same tenant as the row itself.
156
+ *
157
+ * A super admin may edit a role in a tenant other than their own, and
158
+ * `ActionLogService.buildListQuery` filters with strict equality
159
+ * (`organization_id = ?`, applied only when the reader supplies one). Stamping
160
+ * the actor's organization onto a row scoped to a different tenant produces a
161
+ * (tenant B, organization from tenant A) pair that no reader can ever match, so
162
+ * the cross-tenant permission change — exactly the record worth keeping —
163
+ * becomes invisible. `null` is the honest value there: it still reaches
164
+ * tenant-scoped readers whose organization filter is unset.
165
+ */
166
+ function resolveOrganizationId(ctx: CommandRuntimeContext, tenantId: string): string | null {
167
+ if ((ctx.auth?.tenantId ?? null) !== tenantId) return null
168
+ return ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
169
+ }
170
+
171
+ /**
172
+ * A runtime with no cache registered (CLI, workers, tests) is a configuration,
173
+ * not a failure, so resolution is guarded here. An adapter that *is* wired and
174
+ * then throws is a failure, and it propagates to the single error-level guard
175
+ * in `execute` — the same one that covers the `rbacService` half of this
176
+ * invalidation. Reporting one half at `debug` and the other at `error` would
177
+ * hide a nav cache still serving revoked grants.
178
+ */
179
+ async function deleteCacheTags(ctx: CommandRuntimeContext, tags: string[]): Promise<void> {
180
+ let cache: TaggableCache | undefined
181
+ try {
182
+ cache = ctx.container.resolve('cache') as TaggableCache | undefined
183
+ } catch (err) {
184
+ logger.debug('No cache registered for ACL tag invalidation', { err, tags })
185
+ return
186
+ }
187
+ if (!cache?.deleteByTags) return
188
+ await cache.deleteByTags(tags)
189
+ }
190
+
191
+ type AclCommandValues = {
192
+ isSuperAdmin: boolean
193
+ features: string[]
194
+ organizations: string[] | null
195
+ }
196
+
197
+ type AclCommandInput = AclCommandValues & { tenantId: string }
198
+
199
+ export type RoleAclUpdateInput = AclCommandInput & {
200
+ roleId: string
201
+ }
202
+
203
+ export type UserAclUpdateInput = AclCommandInput & {
204
+ userId: string
205
+ /** Mirrors the route's `!hasCustomAcl`: drop the override row instead of upserting it. */
206
+ clear: boolean
207
+ /** Set by the route only when it wrote less than the caller asked for. */
208
+ requested?: SanitizedAclRequest | null
209
+ }
210
+
211
+ export type AclUpdateResult = {
212
+ resourceId: string
213
+ tenantId: string
214
+ organizationId: string | null
215
+ }
216
+
217
+ export const AUTH_ROLE_ACL_UPDATE_COMMAND_ID = 'auth.role-acl.update'
218
+ export const AUTH_USER_ACL_UPDATE_COMMAND_ID = 'auth.user-acl.update'
219
+
220
+ type AclCommandConfig<TInput extends AclCommandInput> = {
221
+ id: string
222
+ resourceKind: string
223
+ labelKey: string
224
+ labelFallback: string
225
+ resourceId: (input: TInput) => string
226
+ loadAcl: (em: EntityManager, input: TInput) => Promise<AclRecord | null>
227
+ persist: (params: { em: EntityManager; input: TInput; existing: AclRecord | null }) => Promise<void>
228
+ invalidate: (params: { ctx: CommandRuntimeContext; input: TInput }) => Promise<void>
229
+ /**
230
+ * Post-state when the row is absent after the write. Only the user command's
231
+ * clear path legitimately removes the row; anywhere else a missing row means
232
+ * the re-read failed, which must be logged as "unknown" rather than guessed.
233
+ */
234
+ snapshotWhenAbsent: (input: TInput) => AclSnapshot | null
235
+ /** Human-readable identity of the target; `null` when it cannot be resolved. */
236
+ loadTarget: (em: EntityManager, input: TInput, ctx: CommandRuntimeContext) => Promise<AclTarget | null>
237
+ /** Only the user route sanitizes, so only it can report a trimmed request. */
238
+ sanitizedRequest?: (input: TInput) => SanitizedAclRequest | null
239
+ }
240
+
241
+ /**
242
+ * Enrichment must never cost the entry.
243
+ *
244
+ * The bus persists the log after `execute` has already committed the permission
245
+ * change, so a throw from the target lookup would leave the write recorded
246
+ * nowhere — the same "committed but unaudited" hole the cache-invalidation
247
+ * guard in `execute` closes. A missing label is a far smaller loss.
248
+ */
249
+ async function loadTargetSafely<TInput extends AclCommandInput>(
250
+ config: AclCommandConfig<TInput>,
251
+ input: TInput,
252
+ ctx: CommandRuntimeContext,
253
+ ): Promise<AclTarget | null> {
254
+ try {
255
+ return await config.loadTarget(resolveEm(ctx).fork(), input, ctx)
256
+ } catch (err) {
257
+ logger.warn('Could not resolve the ACL target for the audit entry', {
258
+ err,
259
+ commandId: config.id,
260
+ resourceId: config.resourceId(input),
261
+ tenantId: input.tenantId,
262
+ })
263
+ return null
264
+ }
265
+ }
266
+
267
+ function buildAuditContext(params: {
268
+ target: AclTarget | null
269
+ effect: AclChangeEffect | null
270
+ sanitizedRequest: SanitizedAclRequest | null
271
+ }): Record<string, unknown> | null {
272
+ const context: Record<string, unknown> = {}
273
+ if (params.target) context.target = params.target
274
+ if (params.effect) context.effect = params.effect
275
+ if (params.sanitizedRequest) context.sanitizedRequest = params.sanitizedRequest
276
+ return Object.keys(context).length ? context : null
277
+ }
278
+
279
+ /**
280
+ * Both ACL commands differ only in entity, lookup key, cache invalidation and
281
+ * labels — `prepare`, `captureAfter` and `buildLog` are otherwise identical.
282
+ * Sharing them here keeps the two audit-entry shapes from drifting apart.
283
+ */
284
+ function createAclUpdateCommand<TInput extends AclCommandInput>(
285
+ config: AclCommandConfig<TInput>,
286
+ ): CommandHandler<TInput, AclUpdateResult> {
287
+ return {
288
+ id: config.id,
289
+ // See "Auth ACL Commands — Undo Policy" at top of file.
290
+ isUndoable: false,
291
+ async prepare(input, ctx) {
292
+ const existing = await config.loadAcl(resolveEm(ctx).fork(), input)
293
+ return { before: captureAclSnapshot(existing) }
294
+ },
295
+ async execute(input, ctx) {
296
+ const em = resolveEm(ctx)
297
+ const existing = await config.loadAcl(em, input)
298
+ await config.persist({ em, input, existing })
299
+ // Cache invalidation runs only after the write commits, never inside the
300
+ // atomic flush — and must never propagate. The command bus persists the
301
+ // action log *after* `execute` returns, so a throw here would commit the
302
+ // permission change and then suppress its audit entry: precisely the
303
+ // "committed but unaudited" hole these commands exist to close, and it
304
+ // would open exactly when infrastructure is already degraded.
305
+ // `RbacService.deleteCacheByTags` awaits the cache adapter without a
306
+ // guard of its own, so an outage does reach us here.
307
+ //
308
+ // The resulting staleness (a revoked grant served from cache until its
309
+ // TTL) is not introduced by swallowing — it happens either way once the
310
+ // adapter is down. Logging at error level keeps it alarmable instead of
311
+ // trading a recorded change for an HTTP 500 that misreports a write which
312
+ // did commit.
313
+ try {
314
+ await config.invalidate({ ctx, input })
315
+ } catch (err) {
316
+ logger.error('ACL cache invalidation failed after a committed permission change', {
317
+ err,
318
+ commandId: config.id,
319
+ resourceId: config.resourceId(input),
320
+ tenantId: input.tenantId,
321
+ })
322
+ }
323
+ return {
324
+ resourceId: config.resourceId(input),
325
+ tenantId: input.tenantId,
326
+ organizationId: resolveOrganizationId(ctx, input.tenantId),
327
+ }
328
+ },
329
+ captureAfter: async (input, _result, ctx) => {
330
+ // Read the committed row back rather than echoing the request, so a grant
331
+ // the route sanitized away can never be logged as if it had been applied.
332
+ const persisted = await config.loadAcl(resolveEm(ctx).fork(), input)
333
+ if (persisted) return captureAclSnapshot(persisted)
334
+ return config.snapshotWhenAbsent(input)
335
+ },
336
+ buildLog: async ({ input, result, ctx, snapshots }) => {
337
+ const before = (snapshots.before as AclSnapshot | undefined) ?? null
338
+ const after = (snapshots.after as AclSnapshot | undefined) ?? null
339
+ // An unknown post-state (`after: null`, the failed re-read) counts as a
340
+ // change: it is the one case where the entry is the only evidence left.
341
+ // Both snapshots leave `captureAclSnapshot` with their grant lists sorted,
342
+ // so the shared order-sensitive comparison is a set comparison here.
343
+ const changed = !before || !after || !snapshotsEqual(before, after)
344
+ const sanitizedRequest = config.sanitizedRequest?.(input) ?? null
345
+ // The admin user-edit page PUTs the ACL on every save, even when only the
346
+ // name or the roles changed, and most accounts carry no per-user override
347
+ // at all — so recording an unchanged ACL faithfully buries the real
348
+ // permission changes under identical empty entries. A request the route
349
+ // trimmed rather than refused also arrives with `before == after`; that
350
+ // one is an escalation attempt and must survive this guard.
351
+ if (!changed && !sanitizedRequest) return { skipLog: true }
352
+ const { translate } = await resolveTranslations()
353
+ const target = await loadTargetSafely(config, input, ctx)
354
+ return {
355
+ actionLabel: translate(config.labelKey, config.labelFallback),
356
+ resourceKind: config.resourceKind,
357
+ resourceId: result.resourceId,
358
+ tenantId: result.tenantId,
359
+ organizationId: result.organizationId,
360
+ snapshotBefore: before,
361
+ snapshotAfter: after,
362
+ context: buildAuditContext({
363
+ target,
364
+ // A sanitized-only entry is written *because* nothing changed, so
365
+ // labelling it would have the trail assert a permission change that
366
+ // never happened.
367
+ effect: changed && before && after ? resolveEffect(before, after) : null,
368
+ sanitizedRequest,
369
+ }),
370
+ }
371
+ },
372
+ }
373
+ }
374
+
375
+ const updateRoleAclCommand = createAclUpdateCommand<RoleAclUpdateInput>({
376
+ id: AUTH_ROLE_ACL_UPDATE_COMMAND_ID,
377
+ resourceKind: 'auth.role_acl',
378
+ labelKey: 'auth.audit.roleAcl.update',
379
+ labelFallback: 'Change role permissions',
380
+ resourceId: (input) => input.roleId,
381
+ loadAcl: (em, input) =>
382
+ em.findOne(RoleAcl, { role: input.roleId as unknown as Role, tenantId: input.tenantId }),
383
+ persist: async ({ em, input, existing }) => {
384
+ const acl =
385
+ (existing as RoleAcl | null) ??
386
+ em.create(RoleAcl, {
387
+ role: em.getReference(Role, input.roleId),
388
+ tenantId: input.tenantId,
389
+ createdAt: new Date(),
390
+ isSuperAdmin: false,
391
+ })
392
+ await withAtomicFlush(
393
+ em,
394
+ [
395
+ () => {
396
+ acl.organizationsJson = input.organizations
397
+ acl.isSuperAdmin = input.isSuperAdmin
398
+ acl.featuresJson = input.features
399
+ em.persist(acl)
400
+ },
401
+ ],
402
+ { transaction: true, label: AUTH_ROLE_ACL_UPDATE_COMMAND_ID },
403
+ )
404
+ },
405
+ invalidate: async ({ ctx, input }) => {
406
+ // Every user in the tenant inherits this role's grants, so the whole tenant
407
+ // scope is invalidated.
408
+ const rbacService = ctx.container.resolve('rbacService') as RbacService
409
+ await rbacService.invalidateTenantCache(input.tenantId)
410
+ // Sidebar nav caches depend on RBAC; invalidate tenant scope nav caches
411
+ await deleteCacheTags(ctx, [`rbac:tenant:${input.tenantId}`])
412
+ },
413
+ snapshotWhenAbsent: () => null,
414
+ // Looked up by id alone: the entry already names this role through
415
+ // `resource_id`, and a tenant predicate would only blank the label when a
416
+ // super admin edits a role from outside the tenant scope they are writing in.
417
+ loadTarget: async (em, input) => {
418
+ const role = await em.findOne(Role, { id: input.roleId } as FilterQuery<Role>)
419
+ if (!role) return null
420
+ return { kind: 'role', id: input.roleId, name: role.name ?? null }
421
+ },
422
+ })
423
+
424
+ const updateUserAclCommand = createAclUpdateCommand<UserAclUpdateInput>({
425
+ id: AUTH_USER_ACL_UPDATE_COMMAND_ID,
426
+ resourceKind: 'auth.user_acl',
427
+ labelKey: 'auth.audit.userAcl.update',
428
+ labelFallback: 'Change user permissions',
429
+ resourceId: (input) => input.userId,
430
+ loadAcl: (em, input) =>
431
+ em.findOne(UserAcl, { user: input.userId as unknown as User, tenantId: input.tenantId }),
432
+ persist: async ({ em, input, existing }) => {
433
+ if (input.clear) {
434
+ if (!existing) return
435
+ const aclToRemove = existing as UserAcl
436
+ await withAtomicFlush(
437
+ em,
438
+ [
439
+ () => {
440
+ em.remove(aclToRemove)
441
+ },
442
+ ],
443
+ { transaction: true, label: AUTH_USER_ACL_UPDATE_COMMAND_ID },
444
+ )
445
+ return
446
+ }
447
+ const acl =
448
+ (existing as UserAcl | null) ??
449
+ em.create(UserAcl, {
450
+ user: em.getReference(User, input.userId),
451
+ tenantId: input.tenantId,
452
+ createdAt: new Date(),
453
+ isSuperAdmin: false,
454
+ })
455
+ await withAtomicFlush(
456
+ em,
457
+ [
458
+ () => {
459
+ acl.isSuperAdmin = input.isSuperAdmin
460
+ acl.featuresJson = input.features
461
+ acl.organizationsJson = input.organizations
462
+ em.persist(acl)
463
+ },
464
+ ],
465
+ { transaction: true, label: AUTH_USER_ACL_UPDATE_COMMAND_ID },
466
+ )
467
+ },
468
+ invalidate: async ({ ctx, input }) => {
469
+ const rbacService = ctx.container.resolve('rbacService') as RbacService
470
+ await rbacService.invalidateUserCache(input.userId)
471
+ await deleteCacheTags(ctx, [`rbac:user:${input.userId}`])
472
+ },
473
+ // Clearing removes the row, so an absent row is the real post-state here.
474
+ snapshotWhenAbsent: (input) => (input.clear ? { ...EMPTY_ACL_SNAPSHOT } : null),
475
+ // `userId` arrives in the request body and `auth:user` carries encrypted
476
+ // personal data, so the lookup is tenant-scoped: an id belonging to another
477
+ // tenant must not be decrypted under this scope and stamped into this
478
+ // tenant's trail. A global account (`users.tenant_id` is nullable) still
479
+ // resolves, mirroring how the role route widens its own lookup.
480
+ loadTarget: async (em, input, ctx) => {
481
+ const user = await findOneWithDecryption(
482
+ em,
483
+ User,
484
+ { id: input.userId, $or: [{ tenantId: input.tenantId }, { tenantId: null }] } as FilterQuery<User>,
485
+ {},
486
+ { tenantId: input.tenantId, organizationId: ctx.auth?.orgId ?? null },
487
+ )
488
+ if (!user) return null
489
+ return { kind: 'user', id: input.userId, email: user.email ?? null, name: user.name ?? null }
490
+ },
491
+ sanitizedRequest: (input) => input.requested ?? null,
492
+ })
493
+
494
+ registerCommand(updateRoleAclCommand)
495
+ registerCommand(updateUserAclCommand)
@@ -18,9 +18,11 @@
18
18
  "auth.actions.contactSupport": "Support kontaktieren",
19
19
  "auth.actions.viewPermissions": "Berechtigungen anzeigen",
20
20
  "auth.actions.viewSessions": "Sitzungen anzeigen",
21
+ "auth.audit.roleAcl.update": "Rollenberechtigungen ändern",
21
22
  "auth.audit.roles.create": "Rolle anlegen",
22
23
  "auth.audit.roles.delete": "Rolle löschen",
23
24
  "auth.audit.roles.update": "Rolle aktualisieren",
25
+ "auth.audit.userAcl.update": "Benutzerberechtigungen ändern",
24
26
  "auth.audit.users.create": "Benutzer anlegen",
25
27
  "auth.audit.users.delete": "Benutzer löschen",
26
28
  "auth.audit.users.update": "Benutzer aktualisieren",
@@ -18,9 +18,11 @@
18
18
  "auth.actions.contactSupport": "Contact Support",
19
19
  "auth.actions.viewPermissions": "View Permissions",
20
20
  "auth.actions.viewSessions": "View Sessions",
21
+ "auth.audit.roleAcl.update": "Change role permissions",
21
22
  "auth.audit.roles.create": "Create role",
22
23
  "auth.audit.roles.delete": "Delete role",
23
24
  "auth.audit.roles.update": "Update role",
25
+ "auth.audit.userAcl.update": "Change user permissions",
24
26
  "auth.audit.users.create": "Create user",
25
27
  "auth.audit.users.delete": "Delete user",
26
28
  "auth.audit.users.update": "Update user",
@@ -18,9 +18,11 @@
18
18
  "auth.actions.contactSupport": "Contactar soporte",
19
19
  "auth.actions.viewPermissions": "Ver permisos",
20
20
  "auth.actions.viewSessions": "Ver sesiones",
21
+ "auth.audit.roleAcl.update": "Cambiar permisos del rol",
21
22
  "auth.audit.roles.create": "Crear rol",
22
23
  "auth.audit.roles.delete": "Eliminar rol",
23
24
  "auth.audit.roles.update": "Actualizar rol",
25
+ "auth.audit.userAcl.update": "Cambiar permisos del usuario",
24
26
  "auth.audit.users.create": "Crear usuario",
25
27
  "auth.audit.users.delete": "Eliminar usuario",
26
28
  "auth.audit.users.update": "Actualizar usuario",
@@ -18,9 +18,11 @@
18
18
  "auth.actions.contactSupport": "지원팀 문의",
19
19
  "auth.actions.viewPermissions": "권한 보기",
20
20
  "auth.actions.viewSessions": "세션 보기",
21
+ "auth.audit.roleAcl.update": "역할 권한 변경",
21
22
  "auth.audit.roles.create": "역할 생성",
22
23
  "auth.audit.roles.delete": "역할 삭제",
23
24
  "auth.audit.roles.update": "역할 수정",
25
+ "auth.audit.userAcl.update": "사용자 권한 변경",
24
26
  "auth.audit.users.create": "사용자 생성",
25
27
  "auth.audit.users.delete": "사용자 삭제",
26
28
  "auth.audit.users.update": "사용자 수정",
@@ -18,9 +18,11 @@
18
18
  "auth.actions.contactSupport": "Skontaktuj się z pomocą",
19
19
  "auth.actions.viewPermissions": "Zobacz uprawnienia",
20
20
  "auth.actions.viewSessions": "Zobacz sesje",
21
+ "auth.audit.roleAcl.update": "Zmień uprawnienia roli",
21
22
  "auth.audit.roles.create": "Utwórz rolę",
22
23
  "auth.audit.roles.delete": "Usuń rolę",
23
24
  "auth.audit.roles.update": "Aktualizuj rolę",
25
+ "auth.audit.userAcl.update": "Zmień uprawnienia użytkownika",
24
26
  "auth.audit.users.create": "Utwórz użytkownika",
25
27
  "auth.audit.users.delete": "Usuń użytkownika",
26
28
  "auth.audit.users.update": "Aktualizuj użytkownika",
@@ -239,6 +239,12 @@ export async function assertActorCanModifySuperAdminRoleTarget(input: SuperAdmin
239
239
  }
240
240
  }
241
241
 
242
+ /**
243
+ * `input.tenantId` is the ACTOR's scope, not the target's: the check below is a
244
+ * comparison between the two, so passing a tenant derived from the target turns
245
+ * it into a tautology. Callers that resolve a record scope separately MUST still
246
+ * hand this guard `auth.tenantId`.
247
+ */
242
248
  export async function assertActorCanAccessUserTarget(input: UserTargetAccessInput): Promise<void> {
243
249
  const isSuperAdmin = await resolveActorIsSuperAdmin(input)
244
250
  if (isSuperAdmin) return
@@ -4,6 +4,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import type { CommandBus, CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
5
5
  import { registerCommand } from '@open-mercato/shared/lib/commands'
6
6
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
+ import { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'
7
8
  import { emitCommunicationChannelsEvent } from '../events'
8
9
  import { resolveContact } from '../lib/contact-resolver'
9
10
  import type { ChannelAdapterRegistry } from '../lib/registry'
@@ -336,10 +337,41 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
336
337
  // ExternalMessage.rawPayload if needed for forensic / forward use.
337
338
  // - Some legitimate messages have no subject (notifications, bounce
338
339
  // digests). Substitute a placeholder instead of failing ingest.
340
+ // - HTML bodies land in the platform body as plain text rather than being
341
+ // merely relabelled as `text`: `MarkdownContent` renders a `text` body
342
+ // verbatim, so unconverted markup shows up as literal doctype, <style>
343
+ // blocks and tags in the inbox preview and the detail body. The source
344
+ // markup is untouched in `channelPayload.html`, which `data/enrichers.ts`
345
+ // sanitizes into `_channelPayload.sanitizedHtml` for the channel-payload
346
+ // renderer widget mounted at `detail:messages:message:body:after`.
347
+ // - The plain-text alternative wins over conversion when the sender's
348
+ // client supplied one (`multipart/alternative`), because that part is
349
+ // what a human actually wrote; `html-to-text` output — inlined link
350
+ // URLs, flattened tables, synthesized list markers — is the fallback for
351
+ // HTML-only senders. `lib/email-mime.ts` preferred `bodyHtml` when
352
+ // picking `body`, but preserved the text part at `channelPayload.text`.
339
353
  const MAX_COMPOSE_BODY = 50_000
354
+ // Bound the synchronous parse: inbound mail is untrusted and the 5MB body
355
+ // ceiling from `lib/email-capabilities.ts` would otherwise block the ingest
356
+ // worker on a single oversized document. This cap counts markup bytes while
357
+ // MAX_COMPOSE_BODY counts text characters, so it can bite first — an
358
+ // ordinary marketing template or a long quoted thread is easily over 512KB
359
+ // of markup and still well under 50k characters of text. It therefore
360
+ // carries the same truncation marker, rather than ending mid-sentence with
361
+ // nothing to tell the reader the rest exists.
362
+ const MAX_HTML_PARSE_INPUT = 512 * 1024
340
363
  const TRUNCATE_MARKER =
341
364
  '\n\n[…message truncated by Open Mercato — full body preserved in ExternalMessage.rawPayload]'
342
- const rawBody = m.body ?? ''
365
+ const sourceBody = m.body ?? ''
366
+ const plainAlternative =
367
+ typeof m.channelPayload?.text === 'string' ? m.channelPayload.text.trim() : ''
368
+ const convertHtmlBody = () => {
369
+ const converted = htmlToPlainText(sourceBody.slice(0, MAX_HTML_PARSE_INPUT))
370
+ return sourceBody.length > MAX_HTML_PARSE_INPUT ? converted + TRUNCATE_MARKER : converted
371
+ }
372
+ const rawBody = m.bodyFormat === 'html'
373
+ ? (plainAlternative || convertHtmlBody())
374
+ : sourceBody
343
375
  const truncatedBody =
344
376
  rawBody.length > MAX_COMPOSE_BODY
345
377
  ? rawBody.slice(0, MAX_COMPOSE_BODY - TRUNCATE_MARKER.length) + TRUNCATE_MARKER
@@ -1,17 +1 @@
1
- import { convert } from 'html-to-text'
2
-
3
- export function htmlToPlainText(html: string): string {
4
- if (!html) return ''
5
- return convert(html, {
6
- wordwrap: false,
7
- preserveNewlines: true,
8
- selectors: [
9
- { selector: 'script', format: 'skip' },
10
- { selector: 'style', format: 'skip' },
11
- ],
12
- })
13
- .replace(/\r\n/g, '\n')
14
- .replace(/\r/g, '\n')
15
- .replace(/\n{3,}/g, '\n\n')
16
- .trim()
17
- }
1
+ export { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'
@@ -288,6 +288,9 @@ export async function GET(req: Request, { params }: { params: { id: string } })
288
288
  senderUserId: threadMessage.senderUserId,
289
289
  senderName: threadSenderName,
290
290
  senderEmail: sender?.email ?? null,
291
+ externalName: threadMessage.externalName ?? null,
292
+ externalEmail: threadMessage.externalEmail ?? null,
293
+ sourceEntityType: threadMessage.sourceEntityType ?? null,
291
294
  body: threadMessage.body,
292
295
  bodyFormat: threadMessage.bodyFormat,
293
296
  sentAt: threadMessage.sentAt,
@@ -60,6 +60,9 @@ export const messageThreadItemSchema = z.object({
60
60
  senderUserId: z.string().uuid(),
61
61
  senderName: z.string().nullable().optional(),
62
62
  senderEmail: z.string().nullable().optional(),
63
+ externalName: z.string().nullable().optional(),
64
+ externalEmail: z.string().nullable().optional(),
65
+ sourceEntityType: z.string().nullable().optional(),
63
66
  body: z.string(),
64
67
  bodyFormat: z.enum(['text', 'markdown']).optional(),
65
68
  sentAt: z.string().nullable().optional(),