@open-mercato/core 0.6.3-develop.3562.1.71049275ff → 0.6.3-develop.3574.1.f3c5dcf345
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/helpers/integration/queue-runner.js +15 -3
- package/dist/helpers/integration/queue-runner.js.map +2 -2
- package/dist/helpers/integration/queue.js +1 -1
- package/dist/helpers/integration/queue.js.map +2 -2
- package/dist/modules/auth/api/roles/acl/route.js +37 -1
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/roles/route.js +36 -2
- package/dist/modules/auth/api/roles/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +36 -1
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +40 -2
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/commands/users.js +3 -0
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/lib/grantChecks.js +97 -2
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
- package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +9 -4
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
- package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
- package/jest.config.cjs +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/queue-runner.ts +21 -2
- package/src/helpers/integration/queue.ts +1 -1
- package/src/modules/auth/api/roles/acl/route.ts +39 -1
- package/src/modules/auth/api/roles/route.ts +38 -2
- package/src/modules/auth/api/users/acl/route.ts +41 -1
- package/src/modules/auth/api/users/route.ts +41 -2
- package/src/modules/auth/commands/users.ts +3 -0
- package/src/modules/auth/lib/grantChecks.ts +116 -2
- package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
- package/src/modules/translations/components/TranslationManager.tsx +9 -4
- package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
|
|
2
|
+
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
3
|
+
import { testLinearRegex } from "@open-mercato/shared/lib/regex/linear";
|
|
2
4
|
import {
|
|
3
5
|
WorkflowEventTrigger,
|
|
4
6
|
WorkflowDefinition,
|
|
@@ -140,11 +142,12 @@ function evaluateCondition(condition, payload) {
|
|
|
140
142
|
if (typeof value !== "string" || typeof expected !== "string") return false;
|
|
141
143
|
if (value.length > MAX_WORKFLOW_REGEX_INPUT_LENGTH) return false;
|
|
142
144
|
if (!isSafeWorkflowRegexPattern(expected)) return false;
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
{
|
|
146
|
+
const result = testLinearRegex(expected, value, {
|
|
147
|
+
maxPatternLength: MAX_WORKFLOW_REGEX_PATTERN_LENGTH,
|
|
148
|
+
maxInputLength: MAX_WORKFLOW_REGEX_INPUT_LENGTH
|
|
149
|
+
});
|
|
150
|
+
return result.ok ? result.matched : false;
|
|
148
151
|
}
|
|
149
152
|
default:
|
|
150
153
|
return false;
|
|
@@ -168,7 +171,9 @@ function getCacheKey(tenantId, organizationId) {
|
|
|
168
171
|
return `${tenantId}:${organizationId}`;
|
|
169
172
|
}
|
|
170
173
|
async function loadLegacyTriggers(em, tenantId, organizationId) {
|
|
171
|
-
const
|
|
174
|
+
const postgresEm = em;
|
|
175
|
+
const legacyTriggers = await findWithDecryption(
|
|
176
|
+
postgresEm,
|
|
172
177
|
WorkflowEventTrigger,
|
|
173
178
|
{
|
|
174
179
|
tenantId,
|
|
@@ -178,14 +183,17 @@ async function loadLegacyTriggers(em, tenantId, organizationId) {
|
|
|
178
183
|
},
|
|
179
184
|
{
|
|
180
185
|
orderBy: { priority: "DESC", createdAt: "ASC" }
|
|
181
|
-
}
|
|
186
|
+
},
|
|
187
|
+
{ tenantId, organizationId }
|
|
182
188
|
);
|
|
183
189
|
const definitionIds = [...new Set(legacyTriggers.map((t) => t.workflowDefinitionId))];
|
|
184
|
-
const definitions = definitionIds.length > 0 ? await
|
|
190
|
+
const definitions = definitionIds.length > 0 ? await findWithDecryption(postgresEm, WorkflowDefinition, {
|
|
185
191
|
id: { $in: definitionIds },
|
|
192
|
+
tenantId,
|
|
193
|
+
organizationId,
|
|
186
194
|
enabled: true,
|
|
187
195
|
deletedAt: null
|
|
188
|
-
}) : [];
|
|
196
|
+
}, {}, { tenantId, organizationId }) : [];
|
|
189
197
|
const definitionMap = new Map(definitions.map((d) => [d.id, d]));
|
|
190
198
|
return legacyTriggers.filter((t) => definitionMap.has(t.workflowDefinitionId)).map((t) => {
|
|
191
199
|
const def = definitionMap.get(t.workflowDefinitionId);
|
|
@@ -208,14 +216,18 @@ async function loadLegacyTriggers(em, tenantId, organizationId) {
|
|
|
208
216
|
});
|
|
209
217
|
}
|
|
210
218
|
async function loadEmbeddedTriggers(em, tenantId, organizationId) {
|
|
211
|
-
const
|
|
219
|
+
const postgresEm = em;
|
|
220
|
+
const definitions = await findWithDecryption(
|
|
221
|
+
postgresEm,
|
|
212
222
|
WorkflowDefinition,
|
|
213
223
|
{
|
|
214
224
|
tenantId,
|
|
215
225
|
organizationId,
|
|
216
226
|
enabled: true,
|
|
217
227
|
deletedAt: null
|
|
218
|
-
}
|
|
228
|
+
},
|
|
229
|
+
{},
|
|
230
|
+
{ tenantId, organizationId }
|
|
219
231
|
);
|
|
220
232
|
const triggers = [];
|
|
221
233
|
for (const def of definitions) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/workflows/lib/event-trigger-service.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Workflows Module - Event Trigger Service\n *\n * Core service for evaluating and processing workflow event triggers.\n * Handles pattern matching, filter evaluation, context mapping, and workflow starting.\n */\n\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport type { CacheService } from '@open-mercato/cache'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport {\n WorkflowEventTrigger,\n WorkflowDefinition,\n WorkflowInstance,\n type TriggerFilterCondition,\n type TriggerContextMapping,\n type WorkflowEventTriggerConfig,\n type WorkflowDefinitionTrigger,\n} from '../data/entities'\nimport { startWorkflow, executeWorkflow } from './workflow-executor'\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface EventTriggerContext {\n eventName: string\n payload: Record<string, unknown>\n tenantId: string\n organizationId: string\n}\n\nexport interface ProcessTriggersResult {\n triggered: number\n skipped: number\n errors: Array<{ triggerId: string; error: string }>\n instances: Array<{ triggerId: string; instanceId: string }>\n}\n\n/**\n * Unified trigger interface for both legacy (entity) and embedded (definition) triggers\n */\nexport interface UnifiedTrigger {\n id: string // For legacy: entity ID, for embedded: `${definitionId}:${triggerId}`\n triggerId: string\n name: string\n description?: string | null\n eventPattern: string\n config: WorkflowEventTriggerConfig | null\n enabled: boolean\n priority: number\n workflowDefinitionId: string\n workflowId: string\n workflowVersion: number\n source: 'legacy' | 'embedded'\n tenantId: string\n organizationId: string\n}\n\ninterface CachedTriggers {\n triggers: UnifiedTrigger[]\n cachedAt: number\n}\n\n// Cache TTL: 5 minutes\nconst TRIGGER_CACHE_TTL = 5 * 60 * 1000\n\n// ============================================================================\n// Pattern Matching\n// ============================================================================\n\n// ============================================================================\n// Filter Evaluation\n// ============================================================================\n\nconst MAX_WORKFLOW_REGEX_PATTERN_LENGTH = 200\nconst MAX_WORKFLOW_REGEX_INPUT_LENGTH = 10_000\n\n/**\n * Get a nested value from an object using dot notation.\n */\nfunction getNestedValue(obj: unknown, path: string): unknown {\n if (obj === null || obj === undefined) return undefined\n\n const parts = path.split('.')\n let current: unknown = obj\n\n for (const part of parts) {\n if (current === null || current === undefined) return undefined\n if (typeof current !== 'object') return undefined\n current = (current as Record<string, unknown>)[part]\n }\n\n return current\n}\n\nfunction getQuantifierEnd(pattern: string, index: number): number | null {\n const char = pattern[index]\n if (char === '*' || char === '+' || char === '?') return index\n if (char !== '{') return null\n\n const closeIndex = pattern.indexOf('}', index + 1)\n if (closeIndex === -1) return null\n\n const body = pattern.slice(index + 1, closeIndex)\n return /^[0-9]+(?:,[0-9]*)?$/.test(body) ? closeIndex : null\n}\n\ninterface RegexGroupFrame {\n hasAlternation: boolean\n hasQuantifier: boolean\n}\n\nfunction isSafeWorkflowRegexPattern(pattern: string): boolean {\n if (pattern.length > MAX_WORKFLOW_REGEX_PATTERN_LENGTH) return false\n\n const groupStack: RegexGroupFrame[] = [{ hasAlternation: false, hasQuantifier: false }]\n let inCharClass = false\n let lastClosedGroup: RegexGroupFrame | null = null\n let lastAtomWasQuantified = false\n\n for (let index = 0; index < pattern.length; index += 1) {\n const char = pattern[index]\n\n if (char === '\\\\') {\n const next = pattern[index + 1]\n if (!inCharClass && (/[1-9]/.test(next ?? '') || (next === 'k' && pattern[index + 2] === '<'))) {\n return false\n }\n index += 1\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (inCharClass) {\n if (char === ']') {\n inCharClass = false\n lastAtomWasQuantified = false\n }\n lastClosedGroup = null\n continue\n }\n\n if (char === '[') {\n inCharClass = true\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === '(') {\n if (pattern[index + 1] === '?') {\n if (pattern[index + 2] !== ':') return false\n index += 2\n }\n\n groupStack.push({ hasAlternation: false, hasQuantifier: false })\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === ')') {\n if (groupStack.length === 1) return false\n lastClosedGroup = groupStack.pop()!\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === '|') {\n groupStack[groupStack.length - 1].hasAlternation = true\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n const quantifierEnd = getQuantifierEnd(pattern, index)\n if (quantifierEnd !== null) {\n if (lastAtomWasQuantified) return false\n if (lastClosedGroup?.hasAlternation || lastClosedGroup?.hasQuantifier) return false\n\n groupStack[groupStack.length - 1].hasQuantifier = true\n lastClosedGroup = null\n lastAtomWasQuantified = true\n index = quantifierEnd\n\n if (pattern[index + 1] === '?') {\n index += 1\n }\n\n continue\n }\n\n lastClosedGroup = null\n lastAtomWasQuantified = false\n }\n\n return groupStack.length === 1 && !inCharClass\n}\n\n/**\n * Evaluate a single filter condition against the event payload.\n */\nfunction evaluateCondition(condition: TriggerFilterCondition, payload: Record<string, unknown>): boolean {\n const value = getNestedValue(payload, condition.field)\n const expected = condition.value\n\n switch (condition.operator) {\n case 'eq':\n return value === expected\n\n case 'neq':\n return value !== expected\n\n case 'gt':\n return typeof value === 'number' && typeof expected === 'number' && value > expected\n\n case 'gte':\n return typeof value === 'number' && typeof expected === 'number' && value >= expected\n\n case 'lt':\n return typeof value === 'number' && typeof expected === 'number' && value < expected\n\n case 'lte':\n return typeof value === 'number' && typeof expected === 'number' && value <= expected\n\n case 'contains':\n if (typeof value === 'string' && typeof expected === 'string') {\n return value.includes(expected)\n }\n if (Array.isArray(value)) {\n return value.includes(expected)\n }\n return false\n\n case 'startsWith':\n return typeof value === 'string' && typeof expected === 'string' && value.startsWith(expected)\n\n case 'endsWith':\n return typeof value === 'string' && typeof expected === 'string' && value.endsWith(expected)\n\n case 'in':\n return Array.isArray(expected) && expected.includes(value)\n\n case 'notIn':\n return Array.isArray(expected) && !expected.includes(value)\n\n case 'exists':\n return value !== undefined && value !== null\n\n case 'notExists':\n return value === undefined || value === null\n\n case 'regex':\n if (typeof value !== 'string' || typeof expected !== 'string') return false\n if (value.length > MAX_WORKFLOW_REGEX_INPUT_LENGTH) return false\n if (!isSafeWorkflowRegexPattern(expected)) return false\n try {\n const regex = new RegExp(expected)\n return regex.test(value)\n } catch {\n return false\n }\n\n default:\n return false\n }\n}\n\n/**\n * Evaluate all filter conditions against the event payload.\n * All conditions must pass (AND logic).\n */\nexport function evaluateFilterConditions(\n conditions: TriggerFilterCondition[] | undefined,\n payload: Record<string, unknown>\n): boolean {\n if (!conditions || conditions.length === 0) return true\n\n return conditions.every(condition => evaluateCondition(condition, payload))\n}\n\n// ============================================================================\n// Context Mapping\n// ============================================================================\n\n/**\n * Map event payload to workflow initial context.\n */\nexport function mapEventToContext(\n mapping: TriggerContextMapping[] | undefined,\n payload: Record<string, unknown>\n): Record<string, unknown> {\n const context: Record<string, unknown> = {}\n\n if (!mapping || mapping.length === 0) return context\n\n for (const item of mapping) {\n const value = getNestedValue(payload, item.sourceExpression)\n context[item.targetKey] = value !== undefined ? value : item.defaultValue\n }\n\n return context\n}\n\n// ============================================================================\n// Trigger Loading with Caching\n// ============================================================================\n\n// In-memory cache for triggers (per tenant/org)\nconst triggerCache = new Map<string, CachedTriggers>()\n\nfunction getCacheKey(tenantId: string, organizationId: string): string {\n return `${tenantId}:${organizationId}`\n}\n\n/**\n * Load legacy triggers from WorkflowEventTrigger entity table.\n * For backward compatibility with existing triggers.\n */\nasync function loadLegacyTriggers(\n em: EntityManager,\n tenantId: string,\n organizationId: string\n): Promise<UnifiedTrigger[]> {\n const legacyTriggers = await em.find(\n WorkflowEventTrigger,\n {\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n },\n {\n orderBy: { priority: 'DESC', createdAt: 'ASC' },\n }\n )\n\n // Get definitions for these triggers to get workflowId\n const definitionIds = [...new Set(legacyTriggers.map(t => t.workflowDefinitionId))]\n const definitions = definitionIds.length > 0 ? await em.find(WorkflowDefinition, {\n id: { $in: definitionIds },\n enabled: true,\n deletedAt: null,\n }) : []\n const definitionMap = new Map(definitions.map(d => [d.id, d]))\n\n return legacyTriggers\n .filter(t => definitionMap.has(t.workflowDefinitionId))\n .map(t => {\n const def = definitionMap.get(t.workflowDefinitionId)!\n return {\n id: t.id,\n triggerId: t.id,\n name: t.name,\n description: t.description,\n eventPattern: t.eventPattern,\n config: t.config ?? null,\n enabled: t.enabled,\n priority: t.priority,\n workflowDefinitionId: t.workflowDefinitionId,\n workflowId: def.workflowId,\n workflowVersion: def.version,\n source: 'legacy' as const,\n tenantId: t.tenantId,\n organizationId: t.organizationId,\n }\n })\n}\n\n/**\n * Load embedded triggers from workflow definitions.\n * New triggers are embedded directly in the definition JSONB.\n */\nasync function loadEmbeddedTriggers(\n em: EntityManager,\n tenantId: string,\n organizationId: string\n): Promise<UnifiedTrigger[]> {\n // Load all enabled definitions that may have triggers\n const definitions = await em.find(\n WorkflowDefinition,\n {\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n }\n )\n\n const triggers: UnifiedTrigger[] = []\n\n for (const def of definitions) {\n const embeddedTriggers = def.definition?.triggers as WorkflowDefinitionTrigger[] | undefined\n if (!embeddedTriggers || embeddedTriggers.length === 0) continue\n\n for (const trigger of embeddedTriggers) {\n if (!trigger.enabled) continue\n\n triggers.push({\n id: `${def.id}:${trigger.triggerId}`,\n triggerId: trigger.triggerId,\n name: trigger.name,\n description: trigger.description ?? null,\n eventPattern: trigger.eventPattern,\n config: trigger.config ?? null,\n enabled: trigger.enabled,\n priority: trigger.priority,\n workflowDefinitionId: def.id,\n workflowId: def.workflowId,\n workflowVersion: def.version,\n source: 'embedded' as const,\n tenantId,\n organizationId,\n })\n }\n }\n\n return triggers\n}\n\n/**\n * Load all enabled triggers for a tenant/organization with caching.\n * Merges both legacy (entity) triggers and embedded (definition) triggers.\n */\nexport async function loadTriggersForTenant(\n em: EntityManager,\n tenantId: string,\n organizationId: string,\n cacheService?: CacheService\n): Promise<UnifiedTrigger[]> {\n const cacheKey = getCacheKey(tenantId, organizationId)\n\n // Check in-memory cache\n const cached = triggerCache.get(cacheKey)\n if (cached && Date.now() - cached.cachedAt < TRIGGER_CACHE_TTL) {\n return cached.triggers\n }\n\n // Load from both sources\n const [legacyTriggers, embeddedTriggers] = await Promise.all([\n loadLegacyTriggers(em, tenantId, organizationId),\n loadEmbeddedTriggers(em, tenantId, organizationId),\n ])\n\n // Merge and sort by priority (higher first)\n const allTriggers = [...legacyTriggers, ...embeddedTriggers]\n .sort((a, b) => b.priority - a.priority)\n\n // Update cache\n triggerCache.set(cacheKey, {\n triggers: allTriggers,\n cachedAt: Date.now(),\n })\n\n return allTriggers\n}\n\n/**\n * Invalidate trigger cache for a tenant/organization.\n * Call this when:\n * - Legacy triggers are created/updated/deleted\n * - Workflow definitions with embedded triggers are created/updated/deleted\n */\nexport function invalidateTriggerCache(tenantId: string, organizationId?: string): void {\n if (organizationId) {\n // Invalidate specific org\n const cacheKey = getCacheKey(tenantId, organizationId)\n triggerCache.delete(cacheKey)\n } else {\n // Invalidate all orgs for tenant\n for (const key of triggerCache.keys()) {\n if (key.startsWith(`${tenantId}:`)) {\n triggerCache.delete(key)\n }\n }\n }\n}\n\n// ============================================================================\n// Trigger Matching\n// ============================================================================\n\n/**\n * Find all triggers that match an event.\n */\nexport async function findMatchingTriggers(\n em: EntityManager,\n context: EventTriggerContext\n): Promise<UnifiedTrigger[]> {\n const triggers = await loadTriggersForTenant(\n em,\n context.tenantId,\n context.organizationId\n )\n\n return triggers.filter(trigger => {\n // Check event pattern\n if (!matchEventPattern(context.eventName, trigger.eventPattern)) {\n return false\n }\n\n // Check filter conditions\n if (!evaluateFilterConditions(trigger.config?.filterConditions, context.payload)) {\n return false\n }\n\n return true\n })\n}\n\n// ============================================================================\n// Trigger Processing\n// ============================================================================\n\n/**\n * Check if max concurrent instances limit is reached.\n */\nasync function checkConcurrencyLimit(\n em: EntityManager,\n trigger: UnifiedTrigger\n): Promise<boolean> {\n const maxInstances = trigger.config?.maxConcurrentInstances\n\n if (!maxInstances) return true // No limit\n\n // Count running instances for this trigger's workflow definition\n const runningCount = await em.count(WorkflowInstance, {\n definitionId: trigger.workflowDefinitionId,\n status: { $in: ['RUNNING', 'WAITING_FOR_ACTIVITIES'] },\n tenantId: trigger.tenantId,\n organizationId: trigger.organizationId,\n deletedAt: null,\n })\n\n return runningCount < maxInstances\n}\n\n/**\n * Process all matching triggers for an event and start workflows.\n */\nexport async function processEventTriggers(\n em: EntityManager,\n container: AwilixContainer,\n context: EventTriggerContext\n): Promise<ProcessTriggersResult> {\n const result: ProcessTriggersResult = {\n triggered: 0,\n skipped: 0,\n errors: [],\n instances: [],\n }\n\n // Find matching triggers\n const triggers = await findMatchingTriggers(em, context)\n\n if (triggers.length === 0) {\n return result\n }\n\n // Process each trigger (definitions already validated during loading)\n for (const trigger of triggers) {\n try {\n // Check concurrency limit\n const canStart = await checkConcurrencyLimit(em, trigger)\n if (!canStart) {\n console.log(`[workflow-trigger] Skipping trigger \"${trigger.name}\": max concurrent instances reached`)\n result.skipped++\n continue\n }\n\n // Map event payload to workflow context\n const mappedContext = mapEventToContext(trigger.config?.contextMapping, context.payload)\n\n // Extract entity info from payload for metadata\n const payloadId = context.payload?.id as string | undefined\n const payloadEntityType = context.payload?.entityType as string | undefined\n\n // Include event metadata and payload in context\n const initialContext = {\n // Include raw event payload fields (e.g., id, organizationId, tenantId)\n ...context.payload,\n // Override with explicit mappings if provided\n ...mappedContext,\n __trigger: {\n triggerId: trigger.id,\n triggerName: trigger.name,\n eventName: context.eventName,\n eventPayload: context.payload,\n triggeredAt: new Date().toISOString(),\n source: trigger.source,\n },\n }\n\n // Start workflow\n const instance = await startWorkflow(em, {\n workflowId: trigger.workflowId,\n version: trigger.workflowVersion,\n initialContext,\n metadata: {\n initiatedBy: `trigger:${trigger.id}`,\n // Include entityId and entityType for widget discovery\n entityId: payloadId,\n entityType: payloadEntityType || trigger.config?.entityType,\n labels: {\n trigger_id: trigger.id,\n trigger_name: trigger.name,\n event_name: context.eventName,\n trigger_source: trigger.source,\n },\n },\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n })\n\n result.triggered++\n result.instances.push({\n triggerId: trigger.id,\n instanceId: instance.id,\n })\n\n // Execute workflow asynchronously (don't wait)\n executeWorkflow(em.fork(), container, instance.id).catch(err => {\n console.error(`[workflow-trigger] Error executing workflow ${instance.id}:`, err)\n })\n\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error)\n console.error(`[workflow-trigger] Error processing trigger \"${trigger.name}\":`, error)\n result.errors.push({\n triggerId: trigger.id,\n error: errorMessage,\n })\n }\n }\n\n return result\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\n * Workflows Module - Event Trigger Service\n *\n * Core service for evaluating and processing workflow event triggers.\n * Handles pattern matching, filter evaluation, context mapping, and workflow starting.\n */\n\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport type { CacheService } from '@open-mercato/cache'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { testLinearRegex } from '@open-mercato/shared/lib/regex/linear'\nimport {\n WorkflowEventTrigger,\n WorkflowDefinition,\n WorkflowInstance,\n type TriggerFilterCondition,\n type TriggerContextMapping,\n type WorkflowEventTriggerConfig,\n type WorkflowDefinitionTrigger,\n} from '../data/entities'\nimport { startWorkflow, executeWorkflow } from './workflow-executor'\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface EventTriggerContext {\n eventName: string\n payload: Record<string, unknown>\n tenantId: string\n organizationId: string\n}\n\nexport interface ProcessTriggersResult {\n triggered: number\n skipped: number\n errors: Array<{ triggerId: string; error: string }>\n instances: Array<{ triggerId: string; instanceId: string }>\n}\n\n/**\n * Unified trigger interface for both legacy (entity) and embedded (definition) triggers\n */\nexport interface UnifiedTrigger {\n id: string // For legacy: entity ID, for embedded: `${definitionId}:${triggerId}`\n triggerId: string\n name: string\n description?: string | null\n eventPattern: string\n config: WorkflowEventTriggerConfig | null\n enabled: boolean\n priority: number\n workflowDefinitionId: string\n workflowId: string\n workflowVersion: number\n source: 'legacy' | 'embedded'\n tenantId: string\n organizationId: string\n}\n\ninterface CachedTriggers {\n triggers: UnifiedTrigger[]\n cachedAt: number\n}\n\n// Cache TTL: 5 minutes\nconst TRIGGER_CACHE_TTL = 5 * 60 * 1000\n\n// ============================================================================\n// Pattern Matching\n// ============================================================================\n\n// ============================================================================\n// Filter Evaluation\n// ============================================================================\n\nconst MAX_WORKFLOW_REGEX_PATTERN_LENGTH = 200\nconst MAX_WORKFLOW_REGEX_INPUT_LENGTH = 10_000\n\n/**\n * Get a nested value from an object using dot notation.\n */\nfunction getNestedValue(obj: unknown, path: string): unknown {\n if (obj === null || obj === undefined) return undefined\n\n const parts = path.split('.')\n let current: unknown = obj\n\n for (const part of parts) {\n if (current === null || current === undefined) return undefined\n if (typeof current !== 'object') return undefined\n current = (current as Record<string, unknown>)[part]\n }\n\n return current\n}\n\nfunction getQuantifierEnd(pattern: string, index: number): number | null {\n const char = pattern[index]\n if (char === '*' || char === '+' || char === '?') return index\n if (char !== '{') return null\n\n const closeIndex = pattern.indexOf('}', index + 1)\n if (closeIndex === -1) return null\n\n const body = pattern.slice(index + 1, closeIndex)\n return /^[0-9]+(?:,[0-9]*)?$/.test(body) ? closeIndex : null\n}\n\ninterface RegexGroupFrame {\n hasAlternation: boolean\n hasQuantifier: boolean\n}\n\nfunction isSafeWorkflowRegexPattern(pattern: string): boolean {\n if (pattern.length > MAX_WORKFLOW_REGEX_PATTERN_LENGTH) return false\n\n const groupStack: RegexGroupFrame[] = [{ hasAlternation: false, hasQuantifier: false }]\n let inCharClass = false\n let lastClosedGroup: RegexGroupFrame | null = null\n let lastAtomWasQuantified = false\n\n for (let index = 0; index < pattern.length; index += 1) {\n const char = pattern[index]\n\n if (char === '\\\\') {\n const next = pattern[index + 1]\n if (!inCharClass && (/[1-9]/.test(next ?? '') || (next === 'k' && pattern[index + 2] === '<'))) {\n return false\n }\n index += 1\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (inCharClass) {\n if (char === ']') {\n inCharClass = false\n lastAtomWasQuantified = false\n }\n lastClosedGroup = null\n continue\n }\n\n if (char === '[') {\n inCharClass = true\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === '(') {\n if (pattern[index + 1] === '?') {\n if (pattern[index + 2] !== ':') return false\n index += 2\n }\n\n groupStack.push({ hasAlternation: false, hasQuantifier: false })\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === ')') {\n if (groupStack.length === 1) return false\n lastClosedGroup = groupStack.pop()!\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === '|') {\n groupStack[groupStack.length - 1].hasAlternation = true\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n const quantifierEnd = getQuantifierEnd(pattern, index)\n if (quantifierEnd !== null) {\n if (lastAtomWasQuantified) return false\n if (lastClosedGroup?.hasAlternation || lastClosedGroup?.hasQuantifier) return false\n\n groupStack[groupStack.length - 1].hasQuantifier = true\n lastClosedGroup = null\n lastAtomWasQuantified = true\n index = quantifierEnd\n\n if (pattern[index + 1] === '?') {\n index += 1\n }\n\n continue\n }\n\n lastClosedGroup = null\n lastAtomWasQuantified = false\n }\n\n return groupStack.length === 1 && !inCharClass\n}\n\n/**\n * Evaluate a single filter condition against the event payload.\n */\nfunction evaluateCondition(condition: TriggerFilterCondition, payload: Record<string, unknown>): boolean {\n const value = getNestedValue(payload, condition.field)\n const expected = condition.value\n\n switch (condition.operator) {\n case 'eq':\n return value === expected\n\n case 'neq':\n return value !== expected\n\n case 'gt':\n return typeof value === 'number' && typeof expected === 'number' && value > expected\n\n case 'gte':\n return typeof value === 'number' && typeof expected === 'number' && value >= expected\n\n case 'lt':\n return typeof value === 'number' && typeof expected === 'number' && value < expected\n\n case 'lte':\n return typeof value === 'number' && typeof expected === 'number' && value <= expected\n\n case 'contains':\n if (typeof value === 'string' && typeof expected === 'string') {\n return value.includes(expected)\n }\n if (Array.isArray(value)) {\n return value.includes(expected)\n }\n return false\n\n case 'startsWith':\n return typeof value === 'string' && typeof expected === 'string' && value.startsWith(expected)\n\n case 'endsWith':\n return typeof value === 'string' && typeof expected === 'string' && value.endsWith(expected)\n\n case 'in':\n return Array.isArray(expected) && expected.includes(value)\n\n case 'notIn':\n return Array.isArray(expected) && !expected.includes(value)\n\n case 'exists':\n return value !== undefined && value !== null\n\n case 'notExists':\n return value === undefined || value === null\n\n case 'regex':\n if (typeof value !== 'string' || typeof expected !== 'string') return false\n if (value.length > MAX_WORKFLOW_REGEX_INPUT_LENGTH) return false\n if (!isSafeWorkflowRegexPattern(expected)) return false\n {\n const result = testLinearRegex(expected, value, {\n maxPatternLength: MAX_WORKFLOW_REGEX_PATTERN_LENGTH,\n maxInputLength: MAX_WORKFLOW_REGEX_INPUT_LENGTH,\n })\n return result.ok ? result.matched : false\n }\n\n default:\n return false\n }\n}\n\n/**\n * Evaluate all filter conditions against the event payload.\n * All conditions must pass (AND logic).\n */\nexport function evaluateFilterConditions(\n conditions: TriggerFilterCondition[] | undefined,\n payload: Record<string, unknown>\n): boolean {\n if (!conditions || conditions.length === 0) return true\n\n return conditions.every(condition => evaluateCondition(condition, payload))\n}\n\n// ============================================================================\n// Context Mapping\n// ============================================================================\n\n/**\n * Map event payload to workflow initial context.\n */\nexport function mapEventToContext(\n mapping: TriggerContextMapping[] | undefined,\n payload: Record<string, unknown>\n): Record<string, unknown> {\n const context: Record<string, unknown> = {}\n\n if (!mapping || mapping.length === 0) return context\n\n for (const item of mapping) {\n const value = getNestedValue(payload, item.sourceExpression)\n context[item.targetKey] = value !== undefined ? value : item.defaultValue\n }\n\n return context\n}\n\n// ============================================================================\n// Trigger Loading with Caching\n// ============================================================================\n\n// In-memory cache for triggers (per tenant/org)\nconst triggerCache = new Map<string, CachedTriggers>()\n\nfunction getCacheKey(tenantId: string, organizationId: string): string {\n return `${tenantId}:${organizationId}`\n}\n\n/**\n * Load legacy triggers from WorkflowEventTrigger entity table.\n * For backward compatibility with existing triggers.\n */\nasync function loadLegacyTriggers(\n em: EntityManager,\n tenantId: string,\n organizationId: string\n): Promise<UnifiedTrigger[]> {\n const postgresEm = em as unknown as PostgreSqlEntityManager\n const legacyTriggers = await findWithDecryption(\n postgresEm,\n WorkflowEventTrigger,\n {\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n },\n {\n orderBy: { priority: 'DESC', createdAt: 'ASC' },\n },\n { tenantId, organizationId },\n )\n\n // Get definitions for these triggers to get workflowId\n const definitionIds = [...new Set(legacyTriggers.map(t => t.workflowDefinitionId))]\n const definitions = definitionIds.length > 0 ? await findWithDecryption(postgresEm, WorkflowDefinition, {\n id: { $in: definitionIds },\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n }, {}, { tenantId, organizationId }) : []\n const definitionMap = new Map(definitions.map(d => [d.id, d]))\n\n return legacyTriggers\n .filter(t => definitionMap.has(t.workflowDefinitionId))\n .map(t => {\n const def = definitionMap.get(t.workflowDefinitionId)!\n return {\n id: t.id,\n triggerId: t.id,\n name: t.name,\n description: t.description,\n eventPattern: t.eventPattern,\n config: t.config ?? null,\n enabled: t.enabled,\n priority: t.priority,\n workflowDefinitionId: t.workflowDefinitionId,\n workflowId: def.workflowId,\n workflowVersion: def.version,\n source: 'legacy' as const,\n tenantId: t.tenantId,\n organizationId: t.organizationId,\n }\n })\n}\n\n/**\n * Load embedded triggers from workflow definitions.\n * New triggers are embedded directly in the definition JSONB.\n */\nasync function loadEmbeddedTriggers(\n em: EntityManager,\n tenantId: string,\n organizationId: string\n): Promise<UnifiedTrigger[]> {\n const postgresEm = em as unknown as PostgreSqlEntityManager\n // Load all enabled definitions that may have triggers\n const definitions = await findWithDecryption(\n postgresEm,\n WorkflowDefinition,\n {\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n },\n {},\n { tenantId, organizationId },\n )\n\n const triggers: UnifiedTrigger[] = []\n\n for (const def of definitions) {\n const embeddedTriggers = def.definition?.triggers as WorkflowDefinitionTrigger[] | undefined\n if (!embeddedTriggers || embeddedTriggers.length === 0) continue\n\n for (const trigger of embeddedTriggers) {\n if (!trigger.enabled) continue\n\n triggers.push({\n id: `${def.id}:${trigger.triggerId}`,\n triggerId: trigger.triggerId,\n name: trigger.name,\n description: trigger.description ?? null,\n eventPattern: trigger.eventPattern,\n config: trigger.config ?? null,\n enabled: trigger.enabled,\n priority: trigger.priority,\n workflowDefinitionId: def.id,\n workflowId: def.workflowId,\n workflowVersion: def.version,\n source: 'embedded' as const,\n tenantId,\n organizationId,\n })\n }\n }\n\n return triggers\n}\n\n/**\n * Load all enabled triggers for a tenant/organization with caching.\n * Merges both legacy (entity) triggers and embedded (definition) triggers.\n */\nexport async function loadTriggersForTenant(\n em: EntityManager,\n tenantId: string,\n organizationId: string,\n cacheService?: CacheService\n): Promise<UnifiedTrigger[]> {\n const cacheKey = getCacheKey(tenantId, organizationId)\n\n // Check in-memory cache\n const cached = triggerCache.get(cacheKey)\n if (cached && Date.now() - cached.cachedAt < TRIGGER_CACHE_TTL) {\n return cached.triggers\n }\n\n // Load from both sources\n const [legacyTriggers, embeddedTriggers] = await Promise.all([\n loadLegacyTriggers(em, tenantId, organizationId),\n loadEmbeddedTriggers(em, tenantId, organizationId),\n ])\n\n // Merge and sort by priority (higher first)\n const allTriggers = [...legacyTriggers, ...embeddedTriggers]\n .sort((a, b) => b.priority - a.priority)\n\n // Update cache\n triggerCache.set(cacheKey, {\n triggers: allTriggers,\n cachedAt: Date.now(),\n })\n\n return allTriggers\n}\n\n/**\n * Invalidate trigger cache for a tenant/organization.\n * Call this when:\n * - Legacy triggers are created/updated/deleted\n * - Workflow definitions with embedded triggers are created/updated/deleted\n */\nexport function invalidateTriggerCache(tenantId: string, organizationId?: string): void {\n if (organizationId) {\n // Invalidate specific org\n const cacheKey = getCacheKey(tenantId, organizationId)\n triggerCache.delete(cacheKey)\n } else {\n // Invalidate all orgs for tenant\n for (const key of triggerCache.keys()) {\n if (key.startsWith(`${tenantId}:`)) {\n triggerCache.delete(key)\n }\n }\n }\n}\n\n// ============================================================================\n// Trigger Matching\n// ============================================================================\n\n/**\n * Find all triggers that match an event.\n */\nexport async function findMatchingTriggers(\n em: EntityManager,\n context: EventTriggerContext\n): Promise<UnifiedTrigger[]> {\n const triggers = await loadTriggersForTenant(\n em,\n context.tenantId,\n context.organizationId\n )\n\n return triggers.filter(trigger => {\n // Check event pattern\n if (!matchEventPattern(context.eventName, trigger.eventPattern)) {\n return false\n }\n\n // Check filter conditions\n if (!evaluateFilterConditions(trigger.config?.filterConditions, context.payload)) {\n return false\n }\n\n return true\n })\n}\n\n// ============================================================================\n// Trigger Processing\n// ============================================================================\n\n/**\n * Check if max concurrent instances limit is reached.\n */\nasync function checkConcurrencyLimit(\n em: EntityManager,\n trigger: UnifiedTrigger\n): Promise<boolean> {\n const maxInstances = trigger.config?.maxConcurrentInstances\n\n if (!maxInstances) return true // No limit\n\n // Count running instances for this trigger's workflow definition\n const runningCount = await em.count(WorkflowInstance, {\n definitionId: trigger.workflowDefinitionId,\n status: { $in: ['RUNNING', 'WAITING_FOR_ACTIVITIES'] },\n tenantId: trigger.tenantId,\n organizationId: trigger.organizationId,\n deletedAt: null,\n })\n\n return runningCount < maxInstances\n}\n\n/**\n * Process all matching triggers for an event and start workflows.\n */\nexport async function processEventTriggers(\n em: EntityManager,\n container: AwilixContainer,\n context: EventTriggerContext\n): Promise<ProcessTriggersResult> {\n const result: ProcessTriggersResult = {\n triggered: 0,\n skipped: 0,\n errors: [],\n instances: [],\n }\n\n // Find matching triggers\n const triggers = await findMatchingTriggers(em, context)\n\n if (triggers.length === 0) {\n return result\n }\n\n // Process each trigger (definitions already validated during loading)\n for (const trigger of triggers) {\n try {\n // Check concurrency limit\n const canStart = await checkConcurrencyLimit(em, trigger)\n if (!canStart) {\n console.log(`[workflow-trigger] Skipping trigger \"${trigger.name}\": max concurrent instances reached`)\n result.skipped++\n continue\n }\n\n // Map event payload to workflow context\n const mappedContext = mapEventToContext(trigger.config?.contextMapping, context.payload)\n\n // Extract entity info from payload for metadata\n const payloadId = context.payload?.id as string | undefined\n const payloadEntityType = context.payload?.entityType as string | undefined\n\n // Include event metadata and payload in context\n const initialContext = {\n // Include raw event payload fields (e.g., id, organizationId, tenantId)\n ...context.payload,\n // Override with explicit mappings if provided\n ...mappedContext,\n __trigger: {\n triggerId: trigger.id,\n triggerName: trigger.name,\n eventName: context.eventName,\n eventPayload: context.payload,\n triggeredAt: new Date().toISOString(),\n source: trigger.source,\n },\n }\n\n // Start workflow\n const instance = await startWorkflow(em, {\n workflowId: trigger.workflowId,\n version: trigger.workflowVersion,\n initialContext,\n metadata: {\n initiatedBy: `trigger:${trigger.id}`,\n // Include entityId and entityType for widget discovery\n entityId: payloadId,\n entityType: payloadEntityType || trigger.config?.entityType,\n labels: {\n trigger_id: trigger.id,\n trigger_name: trigger.name,\n event_name: context.eventName,\n trigger_source: trigger.source,\n },\n },\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n })\n\n result.triggered++\n result.instances.push({\n triggerId: trigger.id,\n instanceId: instance.id,\n })\n\n // Execute workflow asynchronously (don't wait)\n executeWorkflow(em.fork(), container, instance.id).catch(err => {\n console.error(`[workflow-trigger] Error executing workflow ${instance.id}:`, err)\n })\n\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error)\n console.error(`[workflow-trigger] Error processing trigger \"${trigger.name}\":`, error)\n result.errors.push({\n triggerId: trigger.id,\n error: errorMessage,\n })\n }\n }\n\n return result\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/jest.config.cjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.3-develop.
|
|
3
|
+
"version": "0.6.3-develop.3574.1.f3c5dcf345",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -242,16 +242,16 @@
|
|
|
242
242
|
"zod": "^4.4.3"
|
|
243
243
|
},
|
|
244
244
|
"peerDependencies": {
|
|
245
|
-
"@open-mercato/ai-assistant": "0.6.3-develop.
|
|
246
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
247
|
-
"@open-mercato/ui": "0.6.3-develop.
|
|
245
|
+
"@open-mercato/ai-assistant": "0.6.3-develop.3574.1.f3c5dcf345",
|
|
246
|
+
"@open-mercato/shared": "0.6.3-develop.3574.1.f3c5dcf345",
|
|
247
|
+
"@open-mercato/ui": "0.6.3-develop.3574.1.f3c5dcf345",
|
|
248
248
|
"react": "^19.0.0",
|
|
249
249
|
"react-dom": "^19.0.0"
|
|
250
250
|
},
|
|
251
251
|
"devDependencies": {
|
|
252
|
-
"@open-mercato/ai-assistant": "0.6.3-develop.
|
|
253
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
254
|
-
"@open-mercato/ui": "0.6.3-develop.
|
|
252
|
+
"@open-mercato/ai-assistant": "0.6.3-develop.3574.1.f3c5dcf345",
|
|
253
|
+
"@open-mercato/shared": "0.6.3-develop.3574.1.f3c5dcf345",
|
|
254
|
+
"@open-mercato/ui": "0.6.3-develop.3574.1.f3c5dcf345",
|
|
255
255
|
"@testing-library/dom": "^10.4.1",
|
|
256
256
|
"@testing-library/jest-dom": "^6.9.1",
|
|
257
257
|
"@testing-library/react": "^16.3.1",
|
|
@@ -7,13 +7,27 @@ import { createQueue } from '@open-mercato/queue'
|
|
|
7
7
|
export type QueueDrainRunnerOptions = {
|
|
8
8
|
appRoot: string
|
|
9
9
|
jobLimit: number
|
|
10
|
+
queueBaseDir?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type BootstrapData = Awaited<ReturnType<typeof bootstrapFromAppRoot>>
|
|
14
|
+
|
|
15
|
+
const bootstrapCache = new Map<string, Promise<BootstrapData>>()
|
|
16
|
+
|
|
17
|
+
function getBootstrapData(appRoot: string): Promise<BootstrapData> {
|
|
18
|
+
const resolvedAppRoot = path.resolve(appRoot)
|
|
19
|
+
const cached = bootstrapCache.get(resolvedAppRoot)
|
|
20
|
+
if (cached) return cached
|
|
21
|
+
const promise = bootstrapFromAppRoot(resolvedAppRoot)
|
|
22
|
+
bootstrapCache.set(resolvedAppRoot, promise)
|
|
23
|
+
return promise
|
|
10
24
|
}
|
|
11
25
|
|
|
12
26
|
export async function drainQueueFromAppRoot(
|
|
13
27
|
queueName: string,
|
|
14
28
|
options: QueueDrainRunnerOptions,
|
|
15
29
|
): Promise<number> {
|
|
16
|
-
const data = await
|
|
30
|
+
const data = await getBootstrapData(options.appRoot)
|
|
17
31
|
const worker = data.modules
|
|
18
32
|
.flatMap((module) => module.workers ?? [])
|
|
19
33
|
.find((entry) => entry.queue === queueName)
|
|
@@ -21,7 +35,11 @@ export async function drainQueueFromAppRoot(
|
|
|
21
35
|
|
|
22
36
|
const container = await createRequestContainer()
|
|
23
37
|
const queue = createQueue(queueName, 'local', {
|
|
24
|
-
baseDir: path.resolve(
|
|
38
|
+
baseDir: path.resolve(
|
|
39
|
+
options.queueBaseDir?.trim()
|
|
40
|
+
|| process.env.QUEUE_BASE_DIR?.trim()
|
|
41
|
+
|| path.resolve(options.appRoot, '.mercato/queue'),
|
|
42
|
+
),
|
|
25
43
|
concurrency: 1,
|
|
26
44
|
})
|
|
27
45
|
const resolve = <T = unknown>(name: string): T => container.resolve(name) as T
|
|
@@ -53,6 +71,7 @@ export async function runQueueDrainFromEnv(): Promise<void> {
|
|
|
53
71
|
const processed = await drainQueueFromAppRoot(queueName, {
|
|
54
72
|
appRoot: path.resolve(appRoot),
|
|
55
73
|
jobLimit,
|
|
74
|
+
queueBaseDir: process.env.QUEUE_BASE_DIR,
|
|
56
75
|
})
|
|
57
76
|
console.log(JSON.stringify({ processed }))
|
|
58
77
|
}
|
|
@@ -32,7 +32,7 @@ function drainQueueInAppProcess(queueName: string, options: Required<DrainQueueO
|
|
|
32
32
|
OM_INTEGRATION_APP_ROOT: options.appRoot,
|
|
33
33
|
OM_INTEGRATION_QUEUE_NAME: queueName,
|
|
34
34
|
OM_INTEGRATION_QUEUE_JOB_LIMIT: String(options.jobLimit),
|
|
35
|
-
QUEUE_BASE_DIR: path.resolve(options.appRoot, '.mercato/queue'),
|
|
35
|
+
QUEUE_BASE_DIR: process.env.QUEUE_BASE_DIR?.trim() || path.resolve(options.appRoot, '.mercato/queue'),
|
|
36
36
|
},
|
|
37
37
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
38
38
|
})
|
|
@@ -9,7 +9,11 @@ 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 {
|
|
12
|
+
import {
|
|
13
|
+
assertActorCanGrantAcl,
|
|
14
|
+
assertActorCanModifySuperAdminRoleTarget,
|
|
15
|
+
normalizeGrantFeatureList,
|
|
16
|
+
} from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
13
17
|
|
|
14
18
|
type TaggableCache = { deleteByTags?: (tags: string[]) => Promise<void> | void }
|
|
15
19
|
|
|
@@ -71,6 +75,23 @@ export async function GET(req: Request) {
|
|
|
71
75
|
}
|
|
72
76
|
if (!tenantScope && !isSuperAdmin) tenantScope = authTenantId ?? null
|
|
73
77
|
|
|
78
|
+
if (!isSuperAdmin && auth.sub) {
|
|
79
|
+
try {
|
|
80
|
+
await assertActorCanModifySuperAdminRoleTarget({
|
|
81
|
+
em,
|
|
82
|
+
rbacService: container.resolve('rbacService') as RbacService,
|
|
83
|
+
actorUserId: auth.sub,
|
|
84
|
+
tenantId: tenantScope,
|
|
85
|
+
organizationId: auth.orgId ?? null,
|
|
86
|
+
targetRoleId: parsed.data.roleId,
|
|
87
|
+
actorIsSuperAdmin: false,
|
|
88
|
+
})
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
|
|
91
|
+
throw err
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
const acl = tenantScope
|
|
75
96
|
? await em.findOne(RoleAcl, { role, tenantId: tenantScope })
|
|
76
97
|
: null
|
|
@@ -133,6 +154,23 @@ export async function PUT(req: Request) {
|
|
|
133
154
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
134
155
|
}
|
|
135
156
|
|
|
157
|
+
if (!isSuperAdmin && auth.sub) {
|
|
158
|
+
try {
|
|
159
|
+
await assertActorCanModifySuperAdminRoleTarget({
|
|
160
|
+
em,
|
|
161
|
+
rbacService,
|
|
162
|
+
actorUserId: auth.sub,
|
|
163
|
+
tenantId: targetTenantId,
|
|
164
|
+
organizationId: auth.orgId ?? null,
|
|
165
|
+
targetRoleId: parsed.data.roleId,
|
|
166
|
+
actorIsSuperAdmin: false,
|
|
167
|
+
})
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
|
|
170
|
+
throw err
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
136
174
|
let acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId })
|
|
137
175
|
if (!acl) {
|
|
138
176
|
acl = em.create(RoleAcl, {
|
|
@@ -3,6 +3,7 @@ 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 { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
7
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
7
8
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
9
|
import { Role, RoleAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
@@ -13,6 +14,8 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
|
13
14
|
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
14
15
|
import { roleCrudEvents, roleCrudIndexer } from '@open-mercato/core/modules/auth/commands/roles'
|
|
15
16
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
17
|
+
import { assertActorCanModifySuperAdminRoleTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
18
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
16
19
|
|
|
17
20
|
const querySchema = z.object({
|
|
18
21
|
id: z.string().uuid().optional(),
|
|
@@ -87,7 +90,12 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
87
90
|
update: {
|
|
88
91
|
commandId: 'auth.roles.update',
|
|
89
92
|
schema: rawBodySchema,
|
|
90
|
-
mapInput: ({ parsed }) =>
|
|
93
|
+
mapInput: async ({ parsed, ctx }) => {
|
|
94
|
+
if (ctx.request && typeof parsed.id === 'string' && parsed.id.length) {
|
|
95
|
+
await assertCanModifySuperAdminRole(ctx.request, parsed.id)
|
|
96
|
+
}
|
|
97
|
+
return parsed
|
|
98
|
+
},
|
|
91
99
|
response: () => ({ ok: true }),
|
|
92
100
|
},
|
|
93
101
|
delete: {
|
|
@@ -236,7 +244,35 @@ export async function GET(req: Request) {
|
|
|
236
244
|
|
|
237
245
|
export const POST = crud.POST
|
|
238
246
|
export const PUT = crud.PUT
|
|
239
|
-
export const DELETE =
|
|
247
|
+
export const DELETE = async (req: Request) => {
|
|
248
|
+
const targetId = new URL(req.url).searchParams.get('id')
|
|
249
|
+
if (targetId) {
|
|
250
|
+
try {
|
|
251
|
+
await assertCanModifySuperAdminRole(req, targetId)
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof CrudHttpError) {
|
|
254
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
255
|
+
}
|
|
256
|
+
throw err
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return crud.DELETE(req)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function assertCanModifySuperAdminRole(req: Request, targetRoleId: string) {
|
|
263
|
+
const auth = await getAuthFromRequest(req)
|
|
264
|
+
if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
|
|
265
|
+
const container = await createRequestContainer()
|
|
266
|
+
const em = container.resolve('em') as EntityManager
|
|
267
|
+
await assertActorCanModifySuperAdminRoleTarget({
|
|
268
|
+
em,
|
|
269
|
+
rbacService: container.resolve('rbacService') as RbacService,
|
|
270
|
+
actorUserId: auth.sub,
|
|
271
|
+
tenantId: auth.tenantId ?? null,
|
|
272
|
+
organizationId: auth.orgId ?? null,
|
|
273
|
+
targetRoleId,
|
|
274
|
+
})
|
|
275
|
+
}
|
|
240
276
|
|
|
241
277
|
export const openApi: OpenApiRouteDoc = {
|
|
242
278
|
tag: 'Authentication & Accounts',
|
|
@@ -4,8 +4,11 @@ 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 { forbidden, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
8
8
|
import { UserAcl } from '@open-mercato/core/modules/auth/data/entities'
|
|
9
|
+
import { assertActorCanModifySuperAdminUserTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
10
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
11
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
9
12
|
|
|
10
13
|
const getSchema = z.object({ userId: z.string().uuid() })
|
|
11
14
|
const putSchema = z.object({
|
|
@@ -42,6 +45,26 @@ export async function GET(req: Request) {
|
|
|
42
45
|
if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
|
|
43
46
|
const container = await createRequestContainer()
|
|
44
47
|
const em = container.resolve('em') as any
|
|
48
|
+
const rbacService = container.resolve('rbacService') as any
|
|
49
|
+
const actorAcl = auth.sub
|
|
50
|
+
? await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
|
|
51
|
+
: null
|
|
52
|
+
if (!actorAcl?.isSuperAdmin && auth.sub) {
|
|
53
|
+
try {
|
|
54
|
+
await assertActorCanModifySuperAdminUserTarget({
|
|
55
|
+
em: em as EntityManager,
|
|
56
|
+
rbacService: rbacService as RbacService,
|
|
57
|
+
actorUserId: auth.sub,
|
|
58
|
+
tenantId: auth.tenantId ?? null,
|
|
59
|
+
organizationId: auth.orgId ?? null,
|
|
60
|
+
targetUserId: parsed.data.userId,
|
|
61
|
+
actorIsSuperAdmin: false,
|
|
62
|
+
})
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
|
|
65
|
+
throw err
|
|
66
|
+
}
|
|
67
|
+
}
|
|
45
68
|
const acl = await em.findOne(UserAcl, { user: parsed.data.userId as any, tenantId: auth.tenantId as any })
|
|
46
69
|
const response = acl
|
|
47
70
|
? {
|
|
@@ -83,6 +106,23 @@ export async function PUT(req: Request) {
|
|
|
83
106
|
: null
|
|
84
107
|
const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin
|
|
85
108
|
|
|
109
|
+
if (!actorIsSuperAdmin && auth.sub) {
|
|
110
|
+
try {
|
|
111
|
+
await assertActorCanModifySuperAdminUserTarget({
|
|
112
|
+
em: em as EntityManager,
|
|
113
|
+
rbacService: rbacService as RbacService,
|
|
114
|
+
actorUserId: auth.sub,
|
|
115
|
+
tenantId: auth.tenantId ?? null,
|
|
116
|
+
organizationId: auth.orgId ?? null,
|
|
117
|
+
targetUserId: parsed.data.userId,
|
|
118
|
+
actorIsSuperAdmin: false,
|
|
119
|
+
})
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
const requestedFeatures = normalizeFeatureList(parsed.data.features)
|
|
87
127
|
const organizations = Array.isArray(parsed.data.organizations) ? parsed.data.organizations : null
|
|
88
128
|
|
|
@@ -13,7 +13,11 @@ 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 {
|
|
16
|
+
import {
|
|
17
|
+
assertActorCanGrantRoleTokens,
|
|
18
|
+
assertActorCanModifySuperAdminUserTarget,
|
|
19
|
+
listSuperAdminUserIds,
|
|
20
|
+
} from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
17
21
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
18
22
|
import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
|
|
19
23
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
@@ -132,6 +136,9 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
132
136
|
schema: rawBodySchema,
|
|
133
137
|
mapInput: async ({ parsed, ctx }) => {
|
|
134
138
|
if (ctx.request) {
|
|
139
|
+
if (typeof parsed.id === 'string' && parsed.id.length) {
|
|
140
|
+
await assertCanModifySuperAdminTarget(ctx.request, parsed.id)
|
|
141
|
+
}
|
|
135
142
|
await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
|
|
136
143
|
}
|
|
137
144
|
return parsed
|
|
@@ -184,6 +191,10 @@ export async function GET(req: Request) {
|
|
|
184
191
|
return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
|
|
185
192
|
}
|
|
186
193
|
effectiveTenantId = actorTenantId
|
|
194
|
+
const superAdminUserIds = await listSuperAdminUserIds(em, actorTenantId)
|
|
195
|
+
if (superAdminUserIds.size) {
|
|
196
|
+
filters.push({ id: { $nin: Array.from(superAdminUserIds) as any } })
|
|
197
|
+
}
|
|
187
198
|
} else {
|
|
188
199
|
const selectedTenantId = getSelectedTenantFromRequest(req)
|
|
189
200
|
if (typeof selectedTenantId === 'string' && selectedTenantId.trim().length > 0) {
|
|
@@ -437,7 +448,20 @@ export const PUT = async (req: Request) => {
|
|
|
437
448
|
return crud.PUT(req)
|
|
438
449
|
}
|
|
439
450
|
|
|
440
|
-
export const DELETE =
|
|
451
|
+
export const DELETE = async (req: Request) => {
|
|
452
|
+
const targetId = new URL(req.url).searchParams.get('id')
|
|
453
|
+
if (targetId) {
|
|
454
|
+
try {
|
|
455
|
+
await assertCanModifySuperAdminTarget(req, targetId)
|
|
456
|
+
} catch (err) {
|
|
457
|
+
if (err instanceof CrudHttpError) {
|
|
458
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
459
|
+
}
|
|
460
|
+
throw err
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return crud.DELETE(req)
|
|
464
|
+
}
|
|
441
465
|
|
|
442
466
|
async function findUserIdsBySearchTokens(
|
|
443
467
|
em: EntityManager,
|
|
@@ -469,6 +493,21 @@ async function findUserIdsBySearchTokens(
|
|
|
469
493
|
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
470
494
|
}
|
|
471
495
|
|
|
496
|
+
async function assertCanModifySuperAdminTarget(req: Request, targetUserId: string) {
|
|
497
|
+
const auth = await getAuthFromRequest(req)
|
|
498
|
+
if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
|
|
499
|
+
const container = await createRequestContainer()
|
|
500
|
+
const em = container.resolve('em') as EntityManager
|
|
501
|
+
await assertActorCanModifySuperAdminUserTarget({
|
|
502
|
+
em,
|
|
503
|
+
rbacService: container.resolve('rbacService') as RbacService,
|
|
504
|
+
actorUserId: auth.sub,
|
|
505
|
+
tenantId: auth.tenantId ?? null,
|
|
506
|
+
organizationId: auth.orgId ?? null,
|
|
507
|
+
targetUserId,
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
|
|
472
511
|
async function assertCanAssignRoles(req: Request, roles: unknown, payload: Record<string, unknown>) {
|
|
473
512
|
if (!Array.isArray(roles)) return
|
|
474
513
|
const auth = await getAuthFromRequest(req)
|
|
@@ -286,6 +286,7 @@ const createUserCommand: CommandHandler<Record<string, unknown>, CreateUserResul
|
|
|
286
286
|
resourceKind: 'auth.user',
|
|
287
287
|
resourceId: String(user.id),
|
|
288
288
|
tenantId: user.tenantId ? String(user.tenantId) : null,
|
|
289
|
+
organizationId: user.organizationId ? String(user.organizationId) : null,
|
|
289
290
|
snapshotAfter: snapshot.view,
|
|
290
291
|
payload: {
|
|
291
292
|
undo: {
|
|
@@ -559,6 +560,7 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
|
|
|
559
560
|
resourceKind: 'auth.user',
|
|
560
561
|
resourceId: String(result.id),
|
|
561
562
|
tenantId: result.tenantId ? String(result.tenantId) : null,
|
|
563
|
+
organizationId: result.organizationId ? String(result.organizationId) : null,
|
|
562
564
|
changes,
|
|
563
565
|
snapshotBefore: before ?? null,
|
|
564
566
|
snapshotAfter: after,
|
|
@@ -689,6 +691,7 @@ const deleteUserCommand: CommandHandler<{ body?: Record<string, unknown>; query?
|
|
|
689
691
|
resourceId: id,
|
|
690
692
|
snapshotBefore: before ?? null,
|
|
691
693
|
tenantId: before?.tenantId ?? null,
|
|
694
|
+
organizationId: before?.organizationId ?? null,
|
|
692
695
|
payload: {
|
|
693
696
|
undo: {
|
|
694
697
|
before: beforeUndo,
|