@open-mercato/core 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7019.1.f4c01c4b5c

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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/api/keys/route.js +1 -1
  3. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  4. package/dist/modules/api_keys/di.js +9 -0
  5. package/dist/modules/api_keys/di.js.map +7 -0
  6. package/dist/modules/api_keys/services/principalService.js +31 -0
  7. package/dist/modules/api_keys/services/principalService.js.map +7 -0
  8. package/dist/modules/attachments/di.js +11 -1
  9. package/dist/modules/attachments/di.js.map +2 -2
  10. package/dist/modules/attachments/index.js.map +1 -1
  11. package/dist/modules/attachments/lib/attachment-service.js +313 -0
  12. package/dist/modules/attachments/lib/attachment-service.js.map +7 -0
  13. package/dist/modules/attachments/lib/scoped-upload-service.js +15 -2
  14. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  15. package/dist/modules/attachments/lib/upload-limits.js +6 -2
  16. package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
  17. package/dist/modules/auth/data/entities.js.map +2 -2
  18. package/dist/modules/auth/di.js +35 -9
  19. package/dist/modules/auth/di.js.map +2 -2
  20. package/dist/modules/auth/services/principalService.js +200 -0
  21. package/dist/modules/auth/services/principalService.js.map +7 -0
  22. package/dist/modules/auth/services/rbacService.js +67 -24
  23. package/dist/modules/auth/services/rbacService.js.map +3 -3
  24. package/dist/modules/auth/services/roleOrganizationScope.js +41 -0
  25. package/dist/modules/auth/services/roleOrganizationScope.js.map +7 -0
  26. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +1 -1
  27. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  28. package/dist/modules/customers/components/calendar/EventPeekPopover.js +3 -2
  29. package/dist/modules/customers/components/calendar/EventPeekPopover.js.map +2 -2
  30. package/dist/modules/data_sync/lib/abandoned-run.js +19 -0
  31. package/dist/modules/data_sync/lib/abandoned-run.js.map +7 -0
  32. package/dist/modules/data_sync/workers/sync-export.js +5 -1
  33. package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
  34. package/dist/modules/data_sync/workers/sync-import.js +5 -1
  35. package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
  36. package/dist/modules/directory/di.js +9 -0
  37. package/dist/modules/directory/di.js.map +2 -2
  38. package/dist/modules/directory/services/organizationHierarchyService.js +29 -0
  39. package/dist/modules/directory/services/organizationHierarchyService.js.map +7 -0
  40. package/dist/modules/directory/services/organizationScopeService.js +56 -0
  41. package/dist/modules/directory/services/organizationScopeService.js.map +7 -0
  42. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  43. package/dist/modules/warranty_claims/api/portal/attachments/route.js +2 -2
  44. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  45. package/dist/modules/workflows/lib/activity-executor.js +4 -0
  46. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  47. package/package.json +7 -7
  48. package/src/modules/api_keys/api/keys/route.ts +3 -1
  49. package/src/modules/api_keys/di.ts +7 -0
  50. package/src/modules/api_keys/services/principalService.ts +33 -0
  51. package/src/modules/attachments/AGENTS.md +15 -0
  52. package/src/modules/attachments/di.ts +16 -0
  53. package/src/modules/attachments/i18n/de.json +5 -0
  54. package/src/modules/attachments/i18n/en.json +5 -0
  55. package/src/modules/attachments/i18n/es.json +5 -0
  56. package/src/modules/attachments/i18n/ko.json +5 -0
  57. package/src/modules/attachments/i18n/pl.json +5 -0
  58. package/src/modules/attachments/index.ts +10 -0
  59. package/src/modules/attachments/lib/attachment-service.ts +468 -0
  60. package/src/modules/attachments/lib/scoped-upload-service.ts +46 -0
  61. package/src/modules/attachments/lib/upload-limits.ts +6 -2
  62. package/src/modules/auth/data/entities.ts +4 -1
  63. package/src/modules/auth/di.ts +46 -16
  64. package/src/modules/auth/services/principalService.ts +266 -0
  65. package/src/modules/auth/services/rbacService.ts +110 -24
  66. package/src/modules/auth/services/roleOrganizationScope.ts +70 -0
  67. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +1 -1
  68. package/src/modules/customers/components/calendar/EventPeekPopover.tsx +4 -2
  69. package/src/modules/customers/i18n/de.json +1 -1
  70. package/src/modules/customers/i18n/en.json +1 -1
  71. package/src/modules/customers/i18n/es.json +1 -1
  72. package/src/modules/customers/i18n/ko.json +1 -1
  73. package/src/modules/customers/i18n/pl.json +1 -1
  74. package/src/modules/data_sync/lib/abandoned-run.ts +56 -0
  75. package/src/modules/data_sync/workers/sync-export.ts +4 -0
  76. package/src/modules/data_sync/workers/sync-import.ts +4 -0
  77. package/src/modules/directory/di.ts +14 -2
  78. package/src/modules/directory/services/organizationHierarchyService.ts +34 -0
  79. package/src/modules/directory/services/organizationScopeService.ts +85 -0
  80. package/src/modules/directory/utils/organizationScope.ts +4 -15
  81. package/src/modules/warranty_claims/api/portal/attachments/route.ts +2 -2
  82. package/src/modules/workflows/lib/activity-executor.ts +9 -0
@@ -0,0 +1,56 @@
1
+ import type { AbandonedJobInfo } from '@open-mercato/queue'
2
+
3
+ /**
4
+ * Fail the run behind a job the queue gave up on.
5
+ *
6
+ * A resumable data_sync job is ONE long-lived job for a whole run — `runImport` is entered once and
7
+ * stays there for the duration, which for a full backfill is days. Every worker death (a deploy, an
8
+ * OOM) therefore stalls that job, and BullMQ's stalled counter is cumulative for the job's life:
9
+ * past `DATA_SYNC_MAX_STALLED_COUNT` it writes a deferred failure and the next worker fails the job
10
+ * BEFORE calling the processor.
11
+ *
12
+ * So `runImport` never runs, never throws, and never finalizes — and `sync_runs` is left saying
13
+ * `running` for a run nothing is running, forever. That is the residual state `queue-policy.ts`
14
+ * describes as what remains after a job exhausts its stall budget; this repairs it.
15
+ *
16
+ * It only ever moves a non-terminal run: `markStatus` refuses to overwrite `completed` / `failed` /
17
+ * `cancelled`, so a job abandoned after its run has ended is a no-op. `onJobAbandoned` may deliver
18
+ * the same job more than once, which for the same reason is also a no-op.
19
+ *
20
+ * The run's progress job is failed alongside it, the way the worker's own catch path does in
21
+ * `workers/sync-import.ts`. Without that the two halves disagree — the run reads `failed` while the
22
+ * operation still shows as in flight in the top bar — until the progress staleness sweep eventually
23
+ * fails it with a generic no-heartbeat message instead of the reason the queue recorded.
24
+ */
25
+ export async function failAbandonedRun(payload: unknown, info: AbandonedJobInfo): Promise<void> {
26
+ const data = payload as { payload?: { runId?: unknown; scope?: { organizationId?: unknown; tenantId?: unknown } } } | undefined
27
+ const runId = data?.payload?.runId
28
+ const scope = data?.payload?.scope
29
+ // Nothing to repair without both: the run row is keyed by id AND tenant scope.
30
+ if (typeof runId !== 'string' || typeof scope?.organizationId !== 'string' || typeof scope?.tenantId !== 'string') return
31
+
32
+ const runScope = { organizationId: scope.organizationId, tenantId: scope.tenantId }
33
+ const errorMessage = `the queue abandoned this run's job without running it: ${info.reason}`
34
+
35
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
36
+ const container = await createRequestContainer()
37
+ const runService = container.resolve('dataSyncRunService') as {
38
+ markStatus(
39
+ runId: string,
40
+ status: string,
41
+ scope: { organizationId: string; tenantId: string },
42
+ error?: string,
43
+ ): Promise<{ progressJobId?: string | null } | null>
44
+ }
45
+ const run = await runService.markStatus(runId, 'failed', runScope, errorMessage)
46
+ if (!run?.progressJobId) return
47
+
48
+ const progressService = container.resolve('progressService') as {
49
+ failJob(
50
+ jobId: string,
51
+ input: { errorMessage: string },
52
+ ctx: { organizationId: string; tenantId: string },
53
+ ): Promise<unknown>
54
+ }
55
+ await progressService.failJob(run.progressJobId, { errorMessage }, runScope)
56
+ }
@@ -7,6 +7,7 @@ import {
7
7
  DATA_SYNC_LOCK_DURATION_MS,
8
8
  DATA_SYNC_MAX_STALLED_COUNT,
9
9
  } from '../lib/queue-policy'
10
+ import { failAbandonedRun } from '../lib/abandoned-run'
10
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
11
12
 
12
13
  const logger = createLogger('data_sync').child({ component: 'sync-export' })
@@ -27,6 +28,9 @@ export const metadata: WorkerMeta = {
27
28
  concurrency: 5,
28
29
  lockDuration: DATA_SYNC_LOCK_DURATION_MS,
29
30
  maxStalledCount: DATA_SYNC_MAX_STALLED_COUNT,
31
+ // Declared on the worker, not on the enqueueing queue: the abandonment this reports is a worker
32
+ // restart, and the process that restarts never constructs the producer's queue instance.
33
+ onJobAbandoned: failAbandonedRun,
30
34
  }
31
35
 
32
36
  type HandlerContext = JobContext & {
@@ -7,6 +7,7 @@ import {
7
7
  DATA_SYNC_LOCK_DURATION_MS,
8
8
  DATA_SYNC_MAX_STALLED_COUNT,
9
9
  } from '../lib/queue-policy'
10
+ import { failAbandonedRun } from '../lib/abandoned-run'
10
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
11
12
 
12
13
  const logger = createLogger('data_sync').child({ component: 'sync-import' })
@@ -27,6 +28,9 @@ export const metadata: WorkerMeta = {
27
28
  concurrency: 5,
28
29
  lockDuration: DATA_SYNC_LOCK_DURATION_MS,
29
30
  maxStalledCount: DATA_SYNC_MAX_STALLED_COUNT,
31
+ // Declared on the worker, not on the enqueueing queue: the abandonment this reports is a worker
32
+ // restart, and the process that restarts never constructs the producer's queue instance.
33
+ onJobAbandoned: failAbandonedRun,
30
34
  }
31
35
 
32
36
  type HandlerContext = JobContext & {
@@ -1,6 +1,18 @@
1
+ import { asClass, asFunction } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
1
3
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { DefaultOrganizationScopeService } from './services/organizationScopeService'
5
+ import { DefaultOrganizationHierarchyService } from './services/organizationHierarchyService'
6
+
7
+ type OrganizationScopeRbac = ConstructorParameters<typeof DefaultOrganizationScopeService>[1]
2
8
 
3
9
  export function register(container: AppContainer) {
4
- // Directory module has no services yet; placeholder for future overrides
10
+ container.register({
11
+ organizationHierarchyService: asClass(DefaultOrganizationHierarchyService).scoped(),
12
+ organizationScopeService: asFunction((cradle: { em: EntityManager; rbacService: OrganizationScopeRbac }) =>
13
+ new DefaultOrganizationScopeService(cradle.em, cradle.rbacService, container),
14
+ )
15
+ .scoped()
16
+ .proxy(),
17
+ })
5
18
  }
6
-
@@ -0,0 +1,34 @@
1
+ import type { FilterQuery } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { Organization } from '../data/entities'
6
+
7
+ function normalizeIds(values: unknown): string[] {
8
+ if (!Array.isArray(values)) return []
9
+ return Array.from(new Set(values
10
+ .map((value) => typeof value === 'string' ? value.trim() : '')
11
+ .filter(Boolean)))
12
+ }
13
+
14
+ export class DefaultOrganizationHierarchyService implements OrganizationHierarchyService {
15
+ constructor(private readonly em: EntityManager) {}
16
+
17
+ async resolveAncestorIds(input: {
18
+ tenantId: string
19
+ organizationId: string
20
+ }): Promise<string[] | null> {
21
+ const organization = await findOneWithDecryption(
22
+ this.em,
23
+ Organization,
24
+ {
25
+ id: input.organizationId,
26
+ tenant: input.tenantId,
27
+ deletedAt: null,
28
+ } as FilterQuery<Organization>,
29
+ { fields: ['id', 'ancestorIds'] },
30
+ { tenantId: input.tenantId, organizationId: input.organizationId },
31
+ )
32
+ return organization ? normalizeIds(organization.ancestorIds) : null
33
+ }
34
+ }
@@ -0,0 +1,85 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import type {
4
+ OrganizationScopeService,
5
+ OrganizationScopeRequest,
6
+ } from '@open-mercato/shared/lib/auth/principal-service'
7
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
8
+ import { resolveOrganizationScope, resolveOrganizationScopeForRequest } from '../utils/organizationScope'
9
+
10
+ type OrganizationScopeRbac = {
11
+ invalidateUserCache(userId: string): Promise<void>
12
+ loadAcl(
13
+ userId: string,
14
+ scope: { tenantId: string | null; organizationId: string | null },
15
+ ): Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>
16
+ }
17
+
18
+ export class DefaultOrganizationScopeService implements OrganizationScopeService {
19
+ constructor(
20
+ private readonly em: EntityManager,
21
+ private readonly rbac: OrganizationScopeRbac,
22
+ private readonly container: AwilixContainer,
23
+ ) {}
24
+
25
+ resolve(input: {
26
+ auth: AuthContext | null | undefined
27
+ selectedId?: string | null
28
+ tenantId?: string | null
29
+ freshAcl?: boolean
30
+ }) {
31
+ const auth = input.auth
32
+ if (input.freshAcl && auth?.sub) {
33
+ return this.resolveFresh({
34
+ auth,
35
+ selectedId: input.selectedId,
36
+ tenantId: input.tenantId,
37
+ }).then((result) => result.scope)
38
+ }
39
+ return resolveOrganizationScope({
40
+ em: this.em,
41
+ rbac: this.rbac as Parameters<typeof resolveOrganizationScope>[0]['rbac'],
42
+ auth: input.auth,
43
+ selectedId: input.selectedId,
44
+ tenantId: input.tenantId,
45
+ })
46
+ }
47
+
48
+ async resolveFresh(input: {
49
+ auth: NonNullable<AuthContext>
50
+ selectedId?: string | null
51
+ tenantId?: string | null
52
+ }) {
53
+ await this.rbac.invalidateUserCache(input.auth.sub)
54
+ const acl = await this.rbac.loadAcl(input.auth.sub, {
55
+ tenantId: input.tenantId ?? input.auth.tenantId ?? null,
56
+ organizationId: input.selectedId ?? input.auth.orgId ?? null,
57
+ })
58
+ const freshRbac: Parameters<typeof resolveOrganizationScope>[0]['rbac'] = {
59
+ loadAcl: async () => acl,
60
+ }
61
+ const scope = await resolveOrganizationScope({
62
+ em: this.em,
63
+ rbac: freshRbac,
64
+ auth: input.auth,
65
+ selectedId: input.selectedId,
66
+ tenantId: input.tenantId,
67
+ })
68
+ return { scope, acl }
69
+ }
70
+
71
+ resolveForRequest(input: {
72
+ auth: AuthContext | null | undefined
73
+ request?: OrganizationScopeRequest
74
+ selectedId?: string | null
75
+ tenantId?: string | null
76
+ }) {
77
+ return resolveOrganizationScopeForRequest({
78
+ container: this.container,
79
+ auth: input.auth,
80
+ request: input.request,
81
+ selectedId: input.selectedId,
82
+ tenantId: input.tenantId,
83
+ })
84
+ }
85
+ }
@@ -5,6 +5,7 @@ import { Organization } from '@open-mercato/core/modules/directory/data/entities
5
5
  import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
8
+ import type { OrganizationScope } from '@open-mercato/shared/lib/auth/principal-service'
8
9
  import { getCurrentCacheTenant, runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'
9
10
  import { parseSelectedOrganizationCookie, parseSelectedTenantCookie } from './scopeCookies'
10
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -13,19 +14,7 @@ const logger = createLogger('directory').child({ component: 'org-scope-cache' })
13
14
 
14
15
  export { parseSelectedOrganizationCookie, parseSelectedTenantCookie }
15
16
 
16
- export type OrganizationScope = {
17
- selectedId: string | null
18
- filterIds: string[] | null
19
- allowedIds: string[] | null
20
- tenantId: string | null
21
- // True when the caller explicitly selected a concrete organization (cookie /
22
- // selectedId param) that could not be honored — it does not exist for the
23
- // tenant or is not accessible. Reads degrade gracefully (filterIds/selectedId
24
- // fall back to the caller's accessible orgs), but writes MUST fail loudly on
25
- // this so a record never lands under an org the caller did not intend. Absent
26
- // (undefined) means the selection — if any — was honored.
27
- selectionRejected?: boolean
28
- }
17
+ export type { OrganizationScope }
29
18
 
30
19
  // Phase 4 — short-TTL cache for resolveOrganizationScopeForRequest.
31
20
  // OrganizationScope is a pure function of (userId, tenantId, selectedOrgId,
@@ -249,8 +238,8 @@ export async function resolveOrganizationScope({
249
238
  tenantId: tenantIdOverride,
250
239
  }: {
251
240
  em: EntityManager
252
- rbac: RbacService
253
- auth: AuthContext
241
+ rbac: Pick<RbacService, 'loadAcl'>
242
+ auth: AuthContext | null | undefined
254
243
  selectedId?: string | null
255
244
  tenantId?: string | null
256
245
  }): Promise<OrganizationScope> {
@@ -19,7 +19,7 @@ import { readAttachmentMetadata } from '@open-mercato/core/modules/attachments/l
19
19
  import { clearAttachmentThumbnailCache } from '@open-mercato/core/modules/attachments/lib/thumbnailCache'
20
20
  import { isMultipartRequestWithinUploadLimit } from '@open-mercato/core/modules/attachments/lib/upload-limits'
21
21
  import {
22
- ScopedAttachmentUploadError,
22
+ isScopedAttachmentUploadError,
23
23
  type ScopedAttachmentUploadErrorCode,
24
24
  } from '@open-mercato/core/modules/attachments/lib/scoped-upload-service'
25
25
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
@@ -413,7 +413,7 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
413
413
  tags: [CUSTOMER_VISIBLE_ATTACHMENT_TAG],
414
414
  })
415
415
  } catch (error) {
416
- if (error instanceof ScopedAttachmentUploadError) {
416
+ if (isScopedAttachmentUploadError(error)) {
417
417
  const { t } = await resolveTranslations()
418
418
  const [key, fallback] = ATTACHMENT_ERROR_TRANSLATIONS[error.code]
419
419
  ?? ['warranty_claims.portal.detail.attachmentError', 'Attachment upload failed.']
@@ -22,6 +22,7 @@ import {
22
22
  type HostLookup,
23
23
  } from '@open-mercato/shared/lib/url-safety'
24
24
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
25
+ import { isPrivateCrossProcessBroadcastEvent } from '@open-mercato/shared/modules/events'
25
26
  import { callWebhookConfigSchema } from '../data/validators'
26
27
  import { WorkflowActivityJob, WORKFLOW_ACTIVITIES_QUEUE_NAME } from './activity-queue-types'
27
28
  import { logWorkflowEvent } from './event-logger'
@@ -578,6 +579,14 @@ export async function executeEmitEvent(
578
579
  throw new Error('EMIT_EVENT requires "eventName" field')
579
580
  }
580
581
 
582
+ // Workflow definitions are tenant-managed input. Private cross-process
583
+ // events coordinate trusted server state (for example closing an in-memory
584
+ // collaboration room), so allowing a workflow to name one would turn a
585
+ // regular event activity into a cross-process invalidation primitive.
586
+ if (isPrivateCrossProcessBroadcastEvent(eventName)) {
587
+ throw new Error(`EMIT_EVENT cannot emit private cross-process event "${eventName}"`)
588
+ }
589
+
581
590
  // Emissions are fire-and-forget and no subscriber validates the payload shape,
582
591
  // so an unresolved `{{context.x}}` here is even quieter than on the command
583
592
  // path — it ships the literal template to every consumer. Fail loudly instead.