@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/api_keys/api/keys/route.js +1 -1
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/api_keys/di.js +9 -0
- package/dist/modules/api_keys/di.js.map +7 -0
- package/dist/modules/api_keys/services/principalService.js +31 -0
- package/dist/modules/api_keys/services/principalService.js.map +7 -0
- package/dist/modules/attachments/di.js +11 -1
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/index.js.map +1 -1
- package/dist/modules/attachments/lib/attachment-service.js +313 -0
- package/dist/modules/attachments/lib/attachment-service.js.map +7 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js +15 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/upload-limits.js +6 -2
- package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/di.js +35 -9
- package/dist/modules/auth/di.js.map +2 -2
- package/dist/modules/auth/services/principalService.js +200 -0
- package/dist/modules/auth/services/principalService.js.map +7 -0
- package/dist/modules/auth/services/rbacService.js +67 -24
- package/dist/modules/auth/services/rbacService.js.map +3 -3
- package/dist/modules/auth/services/roleOrganizationScope.js +41 -0
- package/dist/modules/auth/services/roleOrganizationScope.js.map +7 -0
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +1 -1
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
- package/dist/modules/customers/components/calendar/EventPeekPopover.js +3 -2
- package/dist/modules/customers/components/calendar/EventPeekPopover.js.map +2 -2
- package/dist/modules/data_sync/lib/abandoned-run.js +19 -0
- package/dist/modules/data_sync/lib/abandoned-run.js.map +7 -0
- package/dist/modules/data_sync/workers/sync-export.js +5 -1
- package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-import.js +5 -1
- package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
- package/dist/modules/directory/di.js +9 -0
- package/dist/modules/directory/di.js.map +2 -2
- package/dist/modules/directory/services/organizationHierarchyService.js +29 -0
- package/dist/modules/directory/services/organizationHierarchyService.js.map +7 -0
- package/dist/modules/directory/services/organizationScopeService.js +56 -0
- package/dist/modules/directory/services/organizationScopeService.js.map +7 -0
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +4 -0
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/api/keys/route.ts +3 -1
- package/src/modules/api_keys/di.ts +7 -0
- package/src/modules/api_keys/services/principalService.ts +33 -0
- package/src/modules/attachments/AGENTS.md +15 -0
- package/src/modules/attachments/di.ts +16 -0
- package/src/modules/attachments/i18n/de.json +5 -0
- package/src/modules/attachments/i18n/en.json +5 -0
- package/src/modules/attachments/i18n/es.json +5 -0
- package/src/modules/attachments/i18n/ko.json +5 -0
- package/src/modules/attachments/i18n/pl.json +5 -0
- package/src/modules/attachments/index.ts +10 -0
- package/src/modules/attachments/lib/attachment-service.ts +468 -0
- package/src/modules/attachments/lib/scoped-upload-service.ts +46 -0
- package/src/modules/attachments/lib/upload-limits.ts +6 -2
- package/src/modules/auth/data/entities.ts +4 -1
- package/src/modules/auth/di.ts +46 -16
- package/src/modules/auth/services/principalService.ts +266 -0
- package/src/modules/auth/services/rbacService.ts +110 -24
- package/src/modules/auth/services/roleOrganizationScope.ts +70 -0
- package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +1 -1
- package/src/modules/customers/components/calendar/EventPeekPopover.tsx +4 -2
- package/src/modules/customers/i18n/de.json +1 -1
- package/src/modules/customers/i18n/en.json +1 -1
- package/src/modules/customers/i18n/es.json +1 -1
- package/src/modules/customers/i18n/ko.json +1 -1
- package/src/modules/customers/i18n/pl.json +1 -1
- package/src/modules/data_sync/lib/abandoned-run.ts +56 -0
- package/src/modules/data_sync/workers/sync-export.ts +4 -0
- package/src/modules/data_sync/workers/sync-import.ts +4 -0
- package/src/modules/directory/di.ts +14 -2
- package/src/modules/directory/services/organizationHierarchyService.ts +34 -0
- package/src/modules/directory/services/organizationScopeService.ts +85 -0
- package/src/modules/directory/utils/organizationScope.ts +4 -15
- package/src/modules/warranty_claims/api/portal/attachments/route.ts +2 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|