@open-mercato/core 0.6.3-develop.3876.1.d40fe4ec2d → 0.6.3-develop.3881.1.0b590ac4eb
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/attachments/api/file/[id]/route.js +7 -2
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +7 -4
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/audit_logs/services/accessLogService.js +127 -8
- package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
- package/dist/modules/auth/di.js +17 -3
- package/dist/modules/auth/di.js.map +2 -2
- package/dist/modules/auth/services/rbacDefaultCache.js +110 -0
- package/dist/modules/auth/services/rbacDefaultCache.js.map +7 -0
- package/dist/modules/currencies/api/currencies/route.js +3 -4
- package/dist/modules/currencies/api/currencies/route.js.map +2 -2
- package/dist/modules/currencies/api/exchange-rates/route.js +3 -4
- package/dist/modules/currencies/api/exchange-rates/route.js.map +2 -2
- package/dist/modules/customers/api/people/route.js +26 -24
- package/dist/modules/customers/api/people/route.js.map +2 -2
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +26 -0
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +7 -0
- package/dist/modules/directory/utils/organizationScope.js +85 -0
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/[id]/page.js +2 -1
- package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/create/page.js +4 -2
- package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +20 -3
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/dist/modules/workflows/components/ActivitiesEditor.js +34 -1
- package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +153 -17
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/StepsEditor.js +31 -0
- package/dist/modules/workflows/components/StepsEditor.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraph.js +3 -2
- package/dist/modules/workflows/components/WorkflowGraph.js.map +2 -2
- package/dist/modules/workflows/components/nodes/WaitForTimerNode.js +54 -0
- package/dist/modules/workflows/components/nodes/WaitForTimerNode.js.map +7 -0
- package/dist/modules/workflows/components/nodes/index.js +3 -1
- package/dist/modules/workflows/components/nodes/index.js.map +2 -2
- package/dist/modules/workflows/data/validators.js +117 -0
- package/dist/modules/workflows/data/validators.js.map +2 -2
- package/dist/modules/workflows/di.js +5 -1
- package/dist/modules/workflows/di.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +42 -1
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-queue-types.js.map +2 -2
- package/dist/modules/workflows/lib/activity-worker-handler.js +24 -0
- package/dist/modules/workflows/lib/activity-worker-handler.js.map +2 -2
- package/dist/modules/workflows/lib/duration.js +32 -0
- package/dist/modules/workflows/lib/duration.js.map +7 -0
- package/dist/modules/workflows/lib/event-logger.js +1 -0
- package/dist/modules/workflows/lib/event-logger.js.map +2 -2
- package/dist/modules/workflows/lib/format-validation-error.js +12 -0
- package/dist/modules/workflows/lib/format-validation-error.js.map +7 -0
- package/dist/modules/workflows/lib/graph-utils.js +6 -3
- package/dist/modules/workflows/lib/graph-utils.js.map +2 -2
- package/dist/modules/workflows/lib/node-type-icons.js +9 -5
- package/dist/modules/workflows/lib/node-type-icons.js.map +2 -2
- package/dist/modules/workflows/lib/signal-handler.js +55 -23
- package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
- package/dist/modules/workflows/lib/step-handler.js +79 -29
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/timer-handler.js +159 -0
- package/dist/modules/workflows/lib/timer-handler.js.map +7 -0
- package/dist/modules/workflows/lib/workflow-executor.js +1 -1
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/dist/modules/workflows/workers/workflow-activities.worker.js +20 -4
- package/dist/modules/workflows/workers/workflow-activities.worker.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/api/file/[id]/route.ts +7 -2
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +7 -4
- package/src/modules/audit_logs/services/accessLogService.ts +179 -15
- package/src/modules/auth/di.ts +26 -3
- package/src/modules/auth/services/rbacDefaultCache.ts +145 -0
- package/src/modules/currencies/api/currencies/route.ts +3 -4
- package/src/modules/currencies/api/exchange-rates/route.ts +3 -4
- package/src/modules/customers/api/people/route.ts +27 -25
- package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +39 -0
- package/src/modules/directory/utils/organizationScope.ts +121 -0
- package/src/modules/workflows/backend/definitions/[id]/page.tsx +3 -2
- package/src/modules/workflows/backend/definitions/create/page.tsx +4 -2
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +18 -1
- package/src/modules/workflows/components/ActivitiesEditor.tsx +40 -0
- package/src/modules/workflows/components/NodeEditDialog.tsx +218 -30
- package/src/modules/workflows/components/StepsEditor.tsx +36 -0
- package/src/modules/workflows/components/WorkflowGraph.tsx +2 -1
- package/src/modules/workflows/components/nodes/WaitForTimerNode.tsx +70 -0
- package/src/modules/workflows/components/nodes/index.ts +3 -0
- package/src/modules/workflows/data/validators.ts +121 -0
- package/src/modules/workflows/di.ts +4 -0
- package/src/modules/workflows/i18n/de.json +10 -1
- package/src/modules/workflows/i18n/en.json +10 -1
- package/src/modules/workflows/i18n/es.json +10 -1
- package/src/modules/workflows/i18n/pl.json +10 -1
- package/src/modules/workflows/lib/activity-executor.ts +86 -2
- package/src/modules/workflows/lib/activity-queue-types.ts +18 -11
- package/src/modules/workflows/lib/activity-worker-handler.ts +29 -0
- package/src/modules/workflows/lib/duration.ts +51 -0
- package/src/modules/workflows/lib/event-logger.ts +1 -0
- package/src/modules/workflows/lib/format-validation-error.ts +30 -0
- package/src/modules/workflows/lib/graph-utils.ts +3 -0
- package/src/modules/workflows/lib/node-type-icons.ts +6 -2
- package/src/modules/workflows/lib/signal-handler.ts +62 -24
- package/src/modules/workflows/lib/step-handler.ts +107 -50
- package/src/modules/workflows/lib/timer-handler.ts +213 -0
- package/src/modules/workflows/lib/workflow-executor.ts +1 -1
- package/src/modules/workflows/workers/workflow-activities.worker.ts +33 -7
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { WORKFLOW_ACTIVITIES_QUEUE_NAME } from "../lib/activity-queue-types.js";
|
|
2
1
|
import { WorkflowInstance } from "../data/entities.js";
|
|
3
2
|
import { logWorkflowEvent } from "../lib/event-logger.js";
|
|
4
3
|
import {
|
|
@@ -9,21 +8,36 @@ import {
|
|
|
9
8
|
executeCallWebhook,
|
|
10
9
|
executeFunction
|
|
11
10
|
} from "../lib/activity-executor.js";
|
|
11
|
+
const WORKFLOW_ACTIVITIES_QUEUE = "workflow-activities";
|
|
12
12
|
const DEFAULT_CONCURRENCY = 1;
|
|
13
13
|
const envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY;
|
|
14
14
|
const metadata = {
|
|
15
|
-
queue:
|
|
15
|
+
queue: WORKFLOW_ACTIVITIES_QUEUE,
|
|
16
16
|
id: "workflows:workflow-activities",
|
|
17
17
|
concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
|
|
18
18
|
};
|
|
19
19
|
async function handle(job, ctx) {
|
|
20
20
|
const { payload } = job;
|
|
21
21
|
const startTime = Date.now();
|
|
22
|
+
const em = ctx.resolve("em");
|
|
23
|
+
const container = ctx;
|
|
24
|
+
if (payload.kind === "timer") {
|
|
25
|
+
console.log(
|
|
26
|
+
`[workflows:activity-worker] Firing timer for instance ${payload.workflowInstanceId} (job ${ctx.jobId})`
|
|
27
|
+
);
|
|
28
|
+
const { fireTimer } = await import("../lib/timer-handler.js");
|
|
29
|
+
await fireTimer(em, container, {
|
|
30
|
+
instanceId: payload.workflowInstanceId,
|
|
31
|
+
stepInstanceId: payload.stepInstanceId,
|
|
32
|
+
tenantId: payload.tenantId,
|
|
33
|
+
organizationId: payload.organizationId,
|
|
34
|
+
userId: payload.userId
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
22
38
|
console.log(
|
|
23
39
|
`[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`
|
|
24
40
|
);
|
|
25
|
-
const em = ctx.resolve("em");
|
|
26
|
-
const container = ctx;
|
|
27
41
|
try {
|
|
28
42
|
const instance = await em.findOne(WorkflowInstance, {
|
|
29
43
|
id: payload.workflowInstanceId,
|
|
@@ -67,6 +81,8 @@ async function handle(job, ctx) {
|
|
|
67
81
|
return await executeCallWebhook(payload.activityConfig, activityContext, { signal });
|
|
68
82
|
case "EXECUTE_FUNCTION":
|
|
69
83
|
return await executeFunction(payload.activityConfig, activityContext, container);
|
|
84
|
+
case "WAIT":
|
|
85
|
+
return { waited: true };
|
|
70
86
|
default:
|
|
71
87
|
throw new Error(`Unsupported activity type: ${payload.activityType}`);
|
|
72
88
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/workflows/workers/workflow-activities.worker.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Workflow Activity Worker\n *\n * Background worker that processes async activities from the workflow queue.\n * Executes activities with timeout, logs events, and triggers workflow resume.\n *\n * This worker is auto-discovered by the queue system and processes jobs from\n * the 'workflow-activities' queue.\n */\n\nimport type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { WORKFLOW_ACTIVITIES_QUEUE_NAME, type WorkflowActivityJob } from '../lib/activity-queue-types'\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { WorkflowInstance } from '../data/entities'\nimport { logWorkflowEvent } from '../lib/event-logger'\nimport {\n executeSendEmail,\n executeCallApi,\n executeEmitEvent,\n executeUpdateEntity,\n executeCallWebhook,\n executeFunction,\n} from '../lib/activity-executor'\n\n// Worker metadata for auto-discovery\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: WORKFLOW_ACTIVITIES_QUEUE_NAME,\n id: 'workflows:workflow-activities',\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\n/**\n * Process a workflow activity job.\n *\n * This handler:\n * 1. Fetches the workflow instance\n * 2. Executes the activity by type with timeout support\n * 3. Logs success/failure events to workflow event log\n * 4. Attempts to resume the workflow if all activities complete\n *\n * @param job - The queued job containing activity payload\n * @param ctx - Job context with DI container access\n */\nexport default async function handle(\n job: QueuedJob<WorkflowActivityJob>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { payload } = job\n const startTime = Date.now()\n\n console.log(\n `[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`\n )\n\n // Resolve services from DI container\n const em = ctx.resolve<EntityManager>('em')\n\n // Create a container-like object from ctx.resolve for activity executors\n // The ctx already has the resolve method we need, we just need to cast it\n const container = ctx as unknown as AwilixContainer\n\n try {\n // Fetch workflow instance with tenant/org scoping\n const instance = await em.findOne(WorkflowInstance, {\n id: payload.workflowInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n if (!instance) {\n throw new Error(\n `Workflow instance ${payload.workflowInstanceId} not found (tenant: ${payload.tenantId}, org: ${payload.organizationId})`\n )\n }\n\n // Build activity execution context\n const activityContext = {\n workflowInstance: instance,\n workflowContext: payload.workflowContext,\n stepContext: payload.stepContext,\n stepInstanceId: payload.stepInstanceId,\n userId: payload.userId,\n }\n\n // Execute activity by type\n const executeActivityByType = async (signal?: AbortSignal) => {\n switch (payload.activityType) {\n case 'SEND_EMAIL':\n return await executeSendEmail(payload.activityConfig, activityContext, container)\n case 'CALL_API':\n return await executeCallApi(\n em,\n payload.activityConfig,\n activityContext,\n container,\n signal\n )\n case 'EMIT_EVENT':\n return await executeEmitEvent(payload.activityConfig, activityContext, container)\n case 'UPDATE_ENTITY':\n return await executeUpdateEntity(\n em,\n payload.activityConfig,\n activityContext,\n container\n )\n case 'CALL_WEBHOOK':\n return await executeCallWebhook(payload.activityConfig, activityContext, { signal })\n case 'EXECUTE_FUNCTION':\n return await executeFunction(payload.activityConfig, activityContext, container)\n default:\n throw new Error(`Unsupported activity type: ${payload.activityType}`)\n }\n }\n\n // Execute with optional timeout. AbortController aborts in-flight fetches\n // when the timeout wins the race, preventing phantom executions.\n let result: any\n if (payload.timeoutMs && payload.timeoutMs > 0) {\n const abortController = new AbortController()\n const timeoutId = setTimeout(() => {\n abortController.abort()\n }, payload.timeoutMs)\n\n try {\n result = await Promise.race([\n executeActivityByType(abortController.signal),\n new Promise((_, reject) =>\n setTimeout(\n () => reject(new Error(`Activity timeout after ${payload.timeoutMs}ms`)),\n payload.timeoutMs\n )\n ),\n ])\n } finally {\n clearTimeout(timeoutId)\n }\n } else {\n result = await executeActivityByType()\n }\n\n const executionTimeMs = Date.now() - startTime\n\n // Log success event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_COMPLETED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n executionTimeMs,\n output: result,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n console.log(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) completed successfully for workflow instance ${payload.workflowInstanceId} in ${executionTimeMs}ms`\n )\n\n // Attempt to resume workflow if all activities complete\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n } catch (error: any) {\n const executionTimeMs = Date.now() - startTime\n\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed for workflow instance ${payload.workflowInstanceId} (attempt ${ctx.attemptNumber}):`,\n error.message\n )\n\n // Log failure event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_FAILED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n error: error.message,\n errorStack: error.stack,\n executionTimeMs,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n // Check if this was final attempt (BullMQ handles retries automatically)\n const maxAttempts = payload.retryPolicy?.maxAttempts || 1\n if (ctx.attemptNumber >= maxAttempts) {\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed after ${maxAttempts} attempts for workflow instance ${payload.workflowInstanceId} - triggering workflow failure handling`\n )\n // Final failure - attempt to resume workflow (may transition to FAILED state)\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n }\n\n // Re-throw to let BullMQ handle retry logic\n throw error\n }\n}\n\n/**\n * Helper to check if workflow can resume after activities complete/fail.\n *\n * This function is called after each activity completes or fails.\n * It checks if all pending activities are done and resumes workflow execution.\n *\n * @param em - Entity manager\n * @param ctx - Handler context with resolve method for DI\n * @param workflowInstanceId - Workflow instance ID to resume\n */\nasync function checkAndResumeWorkflow(\n em: EntityManager,\n ctx: HandlerContext,\n workflowInstanceId: string\n): Promise<void> {\n // Import here to avoid circular dependency\n const { resumeWorkflowAfterActivities } = await import('../lib/workflow-executor')\n\n // Cast ctx to AwilixContainer for the resume function\n const container = ctx as unknown as AwilixContainer\n\n try {\n await resumeWorkflowAfterActivities(em, container, workflowInstanceId)\n } catch (error: any) {\n // Ignore error if workflow not ready to resume yet (activities still pending)\n if (!error.message?.includes('Activities still pending')) {\n console.error(\n `[workflows:activity-worker] Failed to resume workflow instance ${workflowInstanceId}:`,\n error.message\n )\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\n * Workflow Activity Worker\n *\n * Background worker that processes async activities from the workflow queue.\n * Executes activities with timeout, logs events, and triggers workflow resume.\n *\n * This worker is auto-discovered by the queue system and processes jobs from\n * the 'workflow-activities' queue.\n */\n\nimport type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport type { WorkflowActivityJob } from '../lib/activity-queue-types'\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { WorkflowInstance } from '../data/entities'\nimport { logWorkflowEvent } from '../lib/event-logger'\nimport {\n executeSendEmail,\n executeCallApi,\n executeEmitEvent,\n executeUpdateEntity,\n executeCallWebhook,\n executeFunction,\n} from '../lib/activity-executor'\n\n// Worker metadata for auto-discovery.\n// NOTE: `queue` MUST be a string literal (or locally-declared const) so the\n// generator's AST-based extractor can resolve it when Node cannot import the\n// .ts source file directly. Importing `WORKFLOW_ACTIVITIES_QUEUE_NAME` from\n// another module breaks auto-discovery and silently drops the worker from\n// `modules.generated.ts`.\nconst WORKFLOW_ACTIVITIES_QUEUE = 'workflow-activities'\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: WORKFLOW_ACTIVITIES_QUEUE,\n id: 'workflows:workflow-activities',\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\n/**\n * Process a workflow activity job.\n *\n * This handler:\n * 1. Fetches the workflow instance\n * 2. Executes the activity by type with timeout support\n * 3. Logs success/failure events to workflow event log\n * 4. Attempts to resume the workflow if all activities complete\n *\n * @param job - The queued job containing activity payload\n * @param ctx - Job context with DI container access\n */\nexport default async function handle(\n job: QueuedJob<WorkflowActivityJob>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { payload } = job\n const startTime = Date.now()\n\n // Resolve services from DI container\n const em = ctx.resolve<EntityManager>('em')\n\n // Create a container-like object from ctx.resolve for activity executors\n // The ctx already has the resolve method we need, we just need to cast it\n const container = ctx as unknown as AwilixContainer\n\n // Timer jobs (kind: 'timer') are a distinct flow \u2014 they resume a paused\n // workflow at a WAIT_FOR_TIMER step rather than running an activity.\n if (payload.kind === 'timer') {\n console.log(\n `[workflows:activity-worker] Firing timer for instance ${payload.workflowInstanceId} (job ${ctx.jobId})`\n )\n const { fireTimer } = await import('../lib/timer-handler')\n await fireTimer(em, container, {\n instanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n userId: payload.userId,\n })\n return\n }\n\n console.log(\n `[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`\n )\n\n try {\n // Fetch workflow instance with tenant/org scoping\n const instance = await em.findOne(WorkflowInstance, {\n id: payload.workflowInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n if (!instance) {\n throw new Error(\n `Workflow instance ${payload.workflowInstanceId} not found (tenant: ${payload.tenantId}, org: ${payload.organizationId})`\n )\n }\n\n // Build activity execution context\n const activityContext = {\n workflowInstance: instance,\n workflowContext: payload.workflowContext,\n stepContext: payload.stepContext,\n stepInstanceId: payload.stepInstanceId,\n userId: payload.userId,\n }\n\n // Execute activity by type\n const executeActivityByType = async (signal?: AbortSignal) => {\n switch (payload.activityType) {\n case 'SEND_EMAIL':\n return await executeSendEmail(payload.activityConfig, activityContext, container)\n case 'CALL_API':\n return await executeCallApi(\n em,\n payload.activityConfig,\n activityContext,\n container,\n signal\n )\n case 'EMIT_EVENT':\n return await executeEmitEvent(payload.activityConfig, activityContext, container)\n case 'UPDATE_ENTITY':\n return await executeUpdateEntity(\n em,\n payload.activityConfig,\n activityContext,\n container\n )\n case 'CALL_WEBHOOK':\n return await executeCallWebhook(payload.activityConfig, activityContext, { signal })\n case 'EXECUTE_FUNCTION':\n return await executeFunction(payload.activityConfig, activityContext, container)\n case 'WAIT':\n // Delay already handled by queue's delayMs \u2014 return success immediately\n return { waited: true }\n default:\n throw new Error(`Unsupported activity type: ${payload.activityType}`)\n }\n }\n\n // Execute with optional timeout. AbortController aborts in-flight fetches\n // when the timeout wins the race, preventing phantom executions.\n let result: any\n if (payload.timeoutMs && payload.timeoutMs > 0) {\n const abortController = new AbortController()\n const timeoutId = setTimeout(() => {\n abortController.abort()\n }, payload.timeoutMs)\n\n try {\n result = await Promise.race([\n executeActivityByType(abortController.signal),\n new Promise((_, reject) =>\n setTimeout(\n () => reject(new Error(`Activity timeout after ${payload.timeoutMs}ms`)),\n payload.timeoutMs\n )\n ),\n ])\n } finally {\n clearTimeout(timeoutId)\n }\n } else {\n result = await executeActivityByType()\n }\n\n const executionTimeMs = Date.now() - startTime\n\n // Log success event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_COMPLETED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n executionTimeMs,\n output: result,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n console.log(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) completed successfully for workflow instance ${payload.workflowInstanceId} in ${executionTimeMs}ms`\n )\n\n // Attempt to resume workflow if all activities complete\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n } catch (error: any) {\n const executionTimeMs = Date.now() - startTime\n\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed for workflow instance ${payload.workflowInstanceId} (attempt ${ctx.attemptNumber}):`,\n error.message\n )\n\n // Log failure event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_FAILED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n error: error.message,\n errorStack: error.stack,\n executionTimeMs,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n // Check if this was final attempt (BullMQ handles retries automatically)\n const maxAttempts = payload.retryPolicy?.maxAttempts || 1\n if (ctx.attemptNumber >= maxAttempts) {\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed after ${maxAttempts} attempts for workflow instance ${payload.workflowInstanceId} - triggering workflow failure handling`\n )\n // Final failure - attempt to resume workflow (may transition to FAILED state)\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n }\n\n // Re-throw to let BullMQ handle retry logic\n throw error\n }\n}\n\n/**\n * Helper to check if workflow can resume after activities complete/fail.\n *\n * This function is called after each activity completes or fails.\n * It checks if all pending activities are done and resumes workflow execution.\n *\n * @param em - Entity manager\n * @param ctx - Handler context with resolve method for DI\n * @param workflowInstanceId - Workflow instance ID to resume\n */\nasync function checkAndResumeWorkflow(\n em: EntityManager,\n ctx: HandlerContext,\n workflowInstanceId: string\n): Promise<void> {\n // Import here to avoid circular dependency\n const { resumeWorkflowAfterActivities } = await import('../lib/workflow-executor')\n\n // Cast ctx to AwilixContainer for the resume function\n const container = ctx as unknown as AwilixContainer\n\n try {\n await resumeWorkflowAfterActivities(em, container, workflowInstanceId)\n } catch (error: any) {\n // Ignore error if workflow not ready to resume yet (activities still pending)\n if (!error.message?.includes('Activities still pending')) {\n console.error(\n `[workflows:activity-worker] Failed to resume workflow instance ${workflowInstanceId}:`,\n error.message\n )\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
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.3881.1.0b590ac4eb",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -243,16 +243,16 @@
|
|
|
243
243
|
"zod": "^4.4.3"
|
|
244
244
|
},
|
|
245
245
|
"peerDependencies": {
|
|
246
|
-
"@open-mercato/ai-assistant": "0.6.3-develop.
|
|
247
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
248
|
-
"@open-mercato/ui": "0.6.3-develop.
|
|
246
|
+
"@open-mercato/ai-assistant": "0.6.3-develop.3881.1.0b590ac4eb",
|
|
247
|
+
"@open-mercato/shared": "0.6.3-develop.3881.1.0b590ac4eb",
|
|
248
|
+
"@open-mercato/ui": "0.6.3-develop.3881.1.0b590ac4eb",
|
|
249
249
|
"react": "^19.0.0",
|
|
250
250
|
"react-dom": "^19.0.0"
|
|
251
251
|
},
|
|
252
252
|
"devDependencies": {
|
|
253
|
-
"@open-mercato/ai-assistant": "0.6.3-develop.
|
|
254
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
255
|
-
"@open-mercato/ui": "0.6.3-develop.
|
|
253
|
+
"@open-mercato/ai-assistant": "0.6.3-develop.3881.1.0b590ac4eb",
|
|
254
|
+
"@open-mercato/shared": "0.6.3-develop.3881.1.0b590ac4eb",
|
|
255
|
+
"@open-mercato/ui": "0.6.3-develop.3881.1.0b590ac4eb",
|
|
256
256
|
"@testing-library/dom": "^10.4.1",
|
|
257
257
|
"@testing-library/jest-dom": "^6.9.1",
|
|
258
258
|
"@testing-library/react": "^16.3.1",
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
AttachmentPartition,
|
|
8
8
|
} from "@open-mercato/core/modules/attachments/data/entities";
|
|
9
9
|
import type { EntityManager } from "@mikro-orm/postgresql";
|
|
10
|
-
import { checkAttachmentAccess } from "@open-mercato/core/modules/attachments/lib/access";
|
|
10
|
+
import { checkAttachmentAccess, isSuperAdminAuth } from "@open-mercato/core/modules/attachments/lib/access";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { attachmentsTag, attachmentErrorSchema } from "../../openapi";
|
|
13
13
|
import {
|
|
@@ -38,7 +38,12 @@ export async function GET(
|
|
|
38
38
|
(resolve("storageDriverFactory") as StorageDriverFactory | null) ??
|
|
39
39
|
new StorageDriverFactory(em);
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const findFilter: Record<string, unknown> = { id };
|
|
42
|
+
if (auth && !isSuperAdminAuth(auth)) {
|
|
43
|
+
if (auth.tenantId) findFilter.tenantId = auth.tenantId;
|
|
44
|
+
if (auth.orgId) findFilter.organizationId = auth.orgId;
|
|
45
|
+
}
|
|
46
|
+
const attachment = await em.findOne(Attachment, findFilter);
|
|
42
47
|
if (!attachment) {
|
|
43
48
|
return NextResponse.json(
|
|
44
49
|
{ error: "Attachment not found" },
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
writeThumbnailCache,
|
|
12
12
|
} from '@open-mercato/core/modules/attachments/lib/thumbnailCache'
|
|
13
13
|
import { canRenderInlineAttachment } from '@open-mercato/core/modules/attachments/lib/security'
|
|
14
|
-
import { checkAttachmentAccess } from '@open-mercato/core/modules/attachments/lib/access'
|
|
14
|
+
import { checkAttachmentAccess, isSuperAdminAuth } from '@open-mercato/core/modules/attachments/lib/access'
|
|
15
15
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
16
16
|
import { attachmentsTag, imageQuerySchema, attachmentErrorSchema } from '../../../openapi'
|
|
17
17
|
import {
|
|
@@ -53,9 +53,12 @@ export async function GET(
|
|
|
53
53
|
const storageDriverFactory =
|
|
54
54
|
(resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
|
|
55
55
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const findFilter: Record<string, unknown> = { id }
|
|
57
|
+
if (auth && !isSuperAdminAuth(auth)) {
|
|
58
|
+
if (auth.tenantId) findFilter.tenantId = auth.tenantId
|
|
59
|
+
if (auth.orgId) findFilter.organizationId = auth.orgId
|
|
60
|
+
}
|
|
61
|
+
const attachment = await em.findOne(Attachment, findFilter)
|
|
59
62
|
if (!attachment) {
|
|
60
63
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
61
64
|
}
|
|
@@ -25,24 +25,191 @@ const NON_CORE_RETENTION_HOURS = toPositiveNumber(process.env.AUDIT_LOGS_NON_COR
|
|
|
25
25
|
const CORE_RETENTION_MS = CORE_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
|
26
26
|
const NON_CORE_RETENTION_MS = NON_CORE_RETENTION_HOURS * 60 * 60 * 1000
|
|
27
27
|
const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/
|
|
28
|
+
// Postgres has a hard limit of 65k bind parameters per statement. Each access
|
|
29
|
+
// log row uses 10 bind values (see INSERT below), so 500 rows × 10 = 5 000
|
|
30
|
+
// parameters — well below the limit while keeping memory bounded.
|
|
31
|
+
const MAX_BATCH_ROWS = 500
|
|
28
32
|
|
|
29
33
|
let validationWarningLogged = false
|
|
30
34
|
let runtimeValidationAvailable: boolean | null = null
|
|
31
35
|
|
|
36
|
+
// Module-level registry of in-flight access-log writes. Both `log` and
|
|
37
|
+
// `logMany` opt every promise they kick off into this set so that
|
|
38
|
+
// `flushAccessLog()` can drain them. This is what makes the new
|
|
39
|
+
// fire-and-forget CRUD path safe for test code that asserts on `access_logs`
|
|
40
|
+
// rows immediately after a response — the integration harness defaults to
|
|
41
|
+
// blocking via `OM_CRUD_ACCESS_LOG_BLOCKING=1`, and direct callers can opt
|
|
42
|
+
// in to draining explicitly via `flushAccessLog()`.
|
|
43
|
+
const pendingAccessLogWrites = new Set<Promise<unknown>>()
|
|
44
|
+
|
|
45
|
+
function trackPendingAccessLogWrite<T>(promise: Promise<T>): Promise<T> {
|
|
46
|
+
pendingAccessLogWrites.add(promise as unknown as Promise<unknown>)
|
|
47
|
+
promise
|
|
48
|
+
.catch(() => undefined)
|
|
49
|
+
.finally(() => {
|
|
50
|
+
pendingAccessLogWrites.delete(promise as unknown as Promise<unknown>)
|
|
51
|
+
})
|
|
52
|
+
return promise
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function flushAccessLog(): Promise<void> {
|
|
56
|
+
while (pendingAccessLogWrites.size > 0) {
|
|
57
|
+
const snapshot = Array.from(pendingAccessLogWrites)
|
|
58
|
+
await Promise.allSettled(snapshot)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
const isZodRuntimeMissing = (err: unknown) => err instanceof TypeError && typeof err.message === 'string' && err.message.includes('_zod')
|
|
33
63
|
|
|
64
|
+
type RawEncryptedFields = {
|
|
65
|
+
resourceKind?: unknown
|
|
66
|
+
resourceId?: unknown
|
|
67
|
+
accessType?: unknown
|
|
68
|
+
fieldsJson?: unknown
|
|
69
|
+
contextJson?: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function serializeJsonColumn(value: unknown): string | null {
|
|
73
|
+
if (value === null || value === undefined) return null
|
|
74
|
+
if (typeof value === 'string') {
|
|
75
|
+
try {
|
|
76
|
+
JSON.parse(value)
|
|
77
|
+
return value
|
|
78
|
+
} catch {
|
|
79
|
+
return JSON.stringify(value)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return JSON.stringify(value)
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
34
89
|
export class AccessLogService {
|
|
35
90
|
constructor(private readonly em: EntityManager) {}
|
|
36
91
|
|
|
37
92
|
async log(input: AccessLogCreateInput): Promise<AccessLog | null> {
|
|
38
|
-
|
|
93
|
+
const promise = this.logInternal(input)
|
|
94
|
+
return trackPendingAccessLogWrite(promise)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async logMany(inputs: AccessLogCreateInput[]): Promise<number> {
|
|
98
|
+
if (!Array.isArray(inputs) || inputs.length === 0) return 0
|
|
99
|
+
const promise = this.logManyInternal(inputs)
|
|
100
|
+
return trackPendingAccessLogWrite(promise)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
flush(): Promise<void> {
|
|
104
|
+
return flushAccessLog()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async logManyInternal(inputs: AccessLogCreateInput[]): Promise<number> {
|
|
108
|
+
// Parsing in parallel matches the legacy fan-out `Promise.all(map(...service.log()))`
|
|
109
|
+
// path's wall-clock; the previous sequential loop made batched writes slower than
|
|
110
|
+
// un-batched on tenants with encryption enabled and pushed UI integration tests
|
|
111
|
+
// over their dialog-stability budget.
|
|
112
|
+
const parsedResults = await Promise.all(inputs.map((input) => this.parseInput(input)))
|
|
113
|
+
const normalized: AccessLogCreateInput[] = []
|
|
114
|
+
for (const parsed of parsedResults) {
|
|
115
|
+
if (parsed) normalized.push(parsed)
|
|
116
|
+
}
|
|
117
|
+
if (!normalized.length) return 0
|
|
118
|
+
|
|
119
|
+
let written = 0
|
|
120
|
+
for (let offset = 0; offset < normalized.length; offset += MAX_BATCH_ROWS) {
|
|
121
|
+
const chunk = normalized.slice(offset, offset + MAX_BATCH_ROWS)
|
|
122
|
+
written += await this.writeChunk(chunk)
|
|
123
|
+
}
|
|
124
|
+
if (written > 0) {
|
|
125
|
+
const fork = this.em.fork({ useContext: true })
|
|
126
|
+
await this.rotate(fork)
|
|
127
|
+
}
|
|
128
|
+
return written
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async writeChunk(chunk: AccessLogCreateInput[]): Promise<number> {
|
|
132
|
+
if (!chunk.length) return 0
|
|
133
|
+
const fork = this.em.fork({ useContext: true })
|
|
134
|
+
const encryption = resolveTenantEncryptionService(fork as any)
|
|
135
|
+
const createdAt = new Date()
|
|
136
|
+
|
|
137
|
+
// Encrypt every row in parallel so encryption-enabled tenants do not pay
|
|
138
|
+
// the N-rows × per-row latency penalty that the previous sequential
|
|
139
|
+
// for-of loop introduced. The legacy `service.log()` fan-out resolved
|
|
140
|
+
// its 50 encryption calls concurrently via `Promise.all`; preserve that
|
|
141
|
+
// characteristic here so the batched single-INSERT path is strictly
|
|
142
|
+
// faster than the legacy parallel-INSERTs path.
|
|
143
|
+
type PreparedRow = {
|
|
144
|
+
tenantId: string | null
|
|
145
|
+
organizationId: string | null
|
|
146
|
+
data: AccessLogCreateInput
|
|
147
|
+
fields: unknown[] | null
|
|
148
|
+
context: Record<string, unknown> | null
|
|
149
|
+
encrypted: RawEncryptedFields | null
|
|
150
|
+
}
|
|
151
|
+
const prepared: PreparedRow[] = await Promise.all(
|
|
152
|
+
chunk.map(async (data) => {
|
|
153
|
+
const fields = Array.isArray(data.fields) && data.fields.length ? data.fields : null
|
|
154
|
+
const context = data.context && Object.keys(data.context).length ? data.context : null
|
|
155
|
+
const tenantId = data.tenantId ?? null
|
|
156
|
+
const organizationId = data.organizationId ?? null
|
|
157
|
+
const encrypted = encryption
|
|
158
|
+
? ((await encryption.encryptEntityPayload(
|
|
159
|
+
E.audit_logs.access_log,
|
|
160
|
+
{
|
|
161
|
+
resourceKind: data.resourceKind,
|
|
162
|
+
resourceId: data.resourceId,
|
|
163
|
+
accessType: data.accessType,
|
|
164
|
+
fieldsJson: fields,
|
|
165
|
+
contextJson: context,
|
|
166
|
+
},
|
|
167
|
+
tenantId,
|
|
168
|
+
organizationId,
|
|
169
|
+
)) as RawEncryptedFields)
|
|
170
|
+
: null
|
|
171
|
+
return { tenantId, organizationId, data, fields, context, encrypted }
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const placeholders: string[] = []
|
|
176
|
+
const params: unknown[] = []
|
|
177
|
+
for (const row of prepared) {
|
|
178
|
+
const { tenantId, organizationId, data, fields, context, encrypted } = row
|
|
179
|
+
const resourceKindOut = encrypted?.resourceKind ?? data.resourceKind
|
|
180
|
+
const resourceIdOut = encrypted?.resourceId ?? data.resourceId
|
|
181
|
+
const accessTypeOut = encrypted?.accessType ?? data.accessType
|
|
182
|
+
const fieldsOut = encrypted?.fieldsJson ?? fields
|
|
183
|
+
const contextOut = encrypted?.contextJson ?? context
|
|
184
|
+
placeholders.push('(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
185
|
+
params.push(
|
|
186
|
+
tenantId,
|
|
187
|
+
organizationId,
|
|
188
|
+
data.actorUserId ?? null,
|
|
189
|
+
resourceKindOut,
|
|
190
|
+
resourceIdOut,
|
|
191
|
+
accessTypeOut,
|
|
192
|
+
serializeJsonColumn(fieldsOut),
|
|
193
|
+
serializeJsonColumn(contextOut),
|
|
194
|
+
createdAt,
|
|
195
|
+
null,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
if (!placeholders.length) return 0
|
|
199
|
+
const sql = `insert into "access_logs" ("tenant_id", "organization_id", "actor_user_id", "resource_kind", "resource_id", "access_type", "fields_json", "context_json", "created_at", "deleted_at") values ${placeholders.join(', ')}`
|
|
200
|
+
await fork.getConnection().execute(sql, params)
|
|
201
|
+
return chunk.length
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async parseInput(input: AccessLogCreateInput): Promise<AccessLogCreateInput | null> {
|
|
39
205
|
const schema = accessLogCreateSchema as typeof accessLogCreateSchema & { _zod?: unknown }
|
|
40
206
|
const canValidate = Boolean(schema && typeof schema.parse === 'function')
|
|
41
207
|
const shouldValidate = canValidate && runtimeValidationAvailable !== false
|
|
42
208
|
if (shouldValidate) {
|
|
43
209
|
try {
|
|
44
|
-
data = schema.parse(input)
|
|
210
|
+
const data = schema.parse(input)
|
|
45
211
|
runtimeValidationAvailable = true
|
|
212
|
+
return data
|
|
46
213
|
} catch (err) {
|
|
47
214
|
if (!isZodRuntimeMissing(err) && !validationWarningLogged) {
|
|
48
215
|
validationWarningLogged = true
|
|
@@ -50,11 +217,15 @@ export class AccessLogService {
|
|
|
50
217
|
console.warn('[audit_logs] falling back to permissive access log payload parser', err)
|
|
51
218
|
}
|
|
52
219
|
if (isZodRuntimeMissing(err)) runtimeValidationAvailable = false
|
|
53
|
-
|
|
220
|
+
return this.normalizeInput(input)
|
|
54
221
|
}
|
|
55
|
-
} else {
|
|
56
|
-
data = this.normalizeInput(input)
|
|
57
222
|
}
|
|
223
|
+
return this.normalizeInput(input)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async logInternal(input: AccessLogCreateInput): Promise<AccessLog | null> {
|
|
227
|
+
const data = await this.parseInput(input)
|
|
228
|
+
if (!data) return null
|
|
58
229
|
const fork = this.em.fork({ useContext: true })
|
|
59
230
|
const fields = Array.isArray(data.fields) && data.fields.length ? data.fields : null
|
|
60
231
|
const context = data.context && Object.keys(data.context).length ? data.context : null
|
|
@@ -62,13 +233,6 @@ export class AccessLogService {
|
|
|
62
233
|
const tenantId = data.tenantId ?? null
|
|
63
234
|
const organizationId = data.organizationId ?? null
|
|
64
235
|
|
|
65
|
-
type AccessLogEncryptedFields = {
|
|
66
|
-
resourceKind?: unknown
|
|
67
|
-
resourceId?: unknown
|
|
68
|
-
accessType?: unknown
|
|
69
|
-
fieldsJson?: unknown
|
|
70
|
-
contextJson?: unknown
|
|
71
|
-
}
|
|
72
236
|
const encryption = resolveTenantEncryptionService(fork as any)
|
|
73
237
|
const encrypted = encryption
|
|
74
238
|
? ((await encryption.encryptEntityPayload(
|
|
@@ -82,7 +246,7 @@ export class AccessLogService {
|
|
|
82
246
|
},
|
|
83
247
|
tenantId,
|
|
84
248
|
organizationId,
|
|
85
|
-
)) as
|
|
249
|
+
)) as RawEncryptedFields)
|
|
86
250
|
: null
|
|
87
251
|
|
|
88
252
|
const payload = {
|
|
@@ -102,8 +266,8 @@ export class AccessLogService {
|
|
|
102
266
|
payload.resourceKind,
|
|
103
267
|
payload.resourceId,
|
|
104
268
|
payload.accessType,
|
|
105
|
-
|
|
106
|
-
|
|
269
|
+
serializeJsonColumn(payload.fieldsJson),
|
|
270
|
+
serializeJsonColumn(payload.contextJson),
|
|
107
271
|
createdAt,
|
|
108
272
|
null,
|
|
109
273
|
],
|
package/src/modules/auth/di.ts
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
|
-
import { asClass } from 'awilix'
|
|
1
|
+
import { asClass, asFunction } from 'awilix'
|
|
2
2
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import type { CacheStrategy } from '@open-mercato/cache'
|
|
3
5
|
import { AuthService } from '@open-mercato/core/modules/auth/services/authService'
|
|
4
6
|
import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
7
|
+
import {
|
|
8
|
+
createRbacFallbackCache,
|
|
9
|
+
isRbacDefaultCacheEnabled,
|
|
10
|
+
resetRbacFallbackCache,
|
|
11
|
+
} from '@open-mercato/core/modules/auth/services/rbacDefaultCache'
|
|
12
|
+
|
|
13
|
+
export { resetRbacFallbackCache }
|
|
5
14
|
|
|
6
15
|
export function register(container: AppContainer) {
|
|
7
16
|
// Register or override core auth service
|
|
8
17
|
container.register({ authService: asClass(AuthService).scoped() })
|
|
9
|
-
// RBAC service
|
|
10
|
-
|
|
18
|
+
// RBAC service. The bare `asClass(...).scoped()` registration matches
|
|
19
|
+
// develop and is the default. Setting `OM_RBAC_DEFAULT_CACHE=on` opts
|
|
20
|
+
// into the in-process LRU fallback for deployments that don't wire a
|
|
21
|
+
// shared CacheStrategy (CLI scripts, lean test bootstraps). Production
|
|
22
|
+
// bootstraps that already register `cache` via `@open-mercato/cache`
|
|
23
|
+
// preempt this fallback because the container's existing `cache`
|
|
24
|
+
// registration wins when `RbacService` reaches for it.
|
|
25
|
+
if (isRbacDefaultCacheEnabled()) {
|
|
26
|
+
container.register({
|
|
27
|
+
rbacService: asFunction((cradle: { em: EntityManager; cache?: CacheStrategy }) => {
|
|
28
|
+
return new RbacService(cradle.em, cradle.cache ?? createRbacFallbackCache())
|
|
29
|
+
}).scoped(),
|
|
30
|
+
})
|
|
31
|
+
} else {
|
|
32
|
+
container.register({ rbacService: asClass(RbacService).scoped() })
|
|
33
|
+
}
|
|
11
34
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { CacheStrategy, CacheValue, CacheSetOptions, CacheGetOptions } from '@open-mercato/cache'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process-scoped fallback CacheStrategy for RbacService.
|
|
5
|
+
*
|
|
6
|
+
* Used only when no shared `cache` service is registered in DI
|
|
7
|
+
* (CLI scripts, lean test bootstraps, isolated unit harnesses). Production
|
|
8
|
+
* deployments wire `@open-mercato/cache` via bootstrap.ts, which preempts
|
|
9
|
+
* this fallback.
|
|
10
|
+
*
|
|
11
|
+
* Goals:
|
|
12
|
+
* - Match the CacheStrategy contract that RbacService consumes (`get`,
|
|
13
|
+
* `set`, `has`, `delete`, `deleteByTags`, `clear`).
|
|
14
|
+
* - Bound memory: LRU eviction at MAX_ENTRIES.
|
|
15
|
+
* - Honor `OM_RBAC_DEFAULT_CACHE=off` so callers can disable it explicitly.
|
|
16
|
+
* - Stay process-scoped via `globalThis` so HMR / module duplication does
|
|
17
|
+
* not produce divergent caches (same pattern as registerDiRegistrars).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type FallbackEntry = {
|
|
21
|
+
value: CacheValue
|
|
22
|
+
tags: string[]
|
|
23
|
+
expiresAt: number | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type FallbackCache = CacheStrategy & {
|
|
27
|
+
__reset: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const GLOBAL_KEY = '__openMercatoRbacFallbackCache__'
|
|
31
|
+
const MAX_ENTRIES = 5000
|
|
32
|
+
|
|
33
|
+
export function isRbacDefaultCacheEnabled(): boolean {
|
|
34
|
+
// Default OFF — same gating posture as Phases 2/4/5 in this PR. The
|
|
35
|
+
// integration runtime stays on the bare `asClass(RbacService).scoped()`
|
|
36
|
+
// path (matching develop) unless an operator opts in explicitly.
|
|
37
|
+
// Set `OM_RBAC_DEFAULT_CACHE=on` (or `1`/`true`/`yes`) to enable the
|
|
38
|
+
// in-process LRU fallback.
|
|
39
|
+
const raw = process.env.OM_RBAC_DEFAULT_CACHE
|
|
40
|
+
if (raw === undefined) return false
|
|
41
|
+
const normalized = raw.trim().toLowerCase()
|
|
42
|
+
if (!normalized.length) return false
|
|
43
|
+
return normalized === 'on' || normalized === '1' || normalized === 'true' || normalized === 'yes'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nowMs(): number {
|
|
47
|
+
return Date.now()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createCache(): FallbackCache {
|
|
51
|
+
const store = new Map<string, FallbackEntry>()
|
|
52
|
+
const touch = (key: string) => {
|
|
53
|
+
const entry = store.get(key)
|
|
54
|
+
if (!entry) return undefined
|
|
55
|
+
if (entry.expiresAt !== null && entry.expiresAt < nowMs()) {
|
|
56
|
+
store.delete(key)
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
// Move to most-recently-used position (Map preserves insertion order).
|
|
60
|
+
store.delete(key)
|
|
61
|
+
store.set(key, entry)
|
|
62
|
+
return entry
|
|
63
|
+
}
|
|
64
|
+
const evictIfNeeded = () => {
|
|
65
|
+
while (store.size > MAX_ENTRIES) {
|
|
66
|
+
const oldest = store.keys().next().value
|
|
67
|
+
if (typeof oldest !== 'string') break
|
|
68
|
+
store.delete(oldest)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const cache: FallbackCache = {
|
|
72
|
+
async get(key: string, _options?: CacheGetOptions): Promise<CacheValue | null> {
|
|
73
|
+
const entry = touch(key)
|
|
74
|
+
return entry ? entry.value : null
|
|
75
|
+
},
|
|
76
|
+
async set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> {
|
|
77
|
+
const ttl = options?.ttl ?? null
|
|
78
|
+
store.delete(key)
|
|
79
|
+
store.set(key, {
|
|
80
|
+
value,
|
|
81
|
+
tags: options?.tags ?? [],
|
|
82
|
+
expiresAt: typeof ttl === 'number' && ttl > 0 ? nowMs() + ttl : null,
|
|
83
|
+
})
|
|
84
|
+
evictIfNeeded()
|
|
85
|
+
},
|
|
86
|
+
async has(key: string): Promise<boolean> {
|
|
87
|
+
return touch(key) !== undefined
|
|
88
|
+
},
|
|
89
|
+
async delete(key: string): Promise<boolean> {
|
|
90
|
+
return store.delete(key)
|
|
91
|
+
},
|
|
92
|
+
async deleteByTags(tags: string[]): Promise<number> {
|
|
93
|
+
if (!tags.length) return 0
|
|
94
|
+
const tagSet = new Set(tags)
|
|
95
|
+
let removed = 0
|
|
96
|
+
for (const [key, entry] of store.entries()) {
|
|
97
|
+
if (entry.tags.some((tag) => tagSet.has(tag))) {
|
|
98
|
+
store.delete(key)
|
|
99
|
+
removed += 1
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return removed
|
|
103
|
+
},
|
|
104
|
+
async clear(): Promise<number> {
|
|
105
|
+
const count = store.size
|
|
106
|
+
store.clear()
|
|
107
|
+
return count
|
|
108
|
+
},
|
|
109
|
+
async keys(pattern?: string): Promise<string[]> {
|
|
110
|
+
if (!pattern) return Array.from(store.keys())
|
|
111
|
+
const matcher = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
|
|
112
|
+
const regex = new RegExp(`^${matcher}$`)
|
|
113
|
+
return Array.from(store.keys()).filter((key) => regex.test(key))
|
|
114
|
+
},
|
|
115
|
+
async size(): Promise<number> {
|
|
116
|
+
return store.size
|
|
117
|
+
},
|
|
118
|
+
async stats(): Promise<{ size: number; expired: number }> {
|
|
119
|
+
const now = nowMs()
|
|
120
|
+
let expired = 0
|
|
121
|
+
for (const entry of store.values()) {
|
|
122
|
+
if (entry.expiresAt !== null && entry.expiresAt < now) expired += 1
|
|
123
|
+
}
|
|
124
|
+
return { size: store.size, expired }
|
|
125
|
+
},
|
|
126
|
+
__reset() {
|
|
127
|
+
store.clear()
|
|
128
|
+
},
|
|
129
|
+
} as FallbackCache
|
|
130
|
+
return cache
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createRbacFallbackCache(): CacheStrategy {
|
|
134
|
+
const existing = (globalThis as any)[GLOBAL_KEY] as FallbackCache | undefined
|
|
135
|
+
if (existing) return existing
|
|
136
|
+
const cache = createCache()
|
|
137
|
+
;(globalThis as any)[GLOBAL_KEY] = cache
|
|
138
|
+
return cache
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Test-only helper. Clears the process-scoped fallback cache. */
|
|
142
|
+
export function resetRbacFallbackCache(): void {
|
|
143
|
+
const existing = (globalThis as any)[GLOBAL_KEY] as FallbackCache | undefined
|
|
144
|
+
existing?.__reset()
|
|
145
|
+
}
|
|
@@ -172,10 +172,9 @@ export async function GET(req: Request) {
|
|
|
172
172
|
orderBy.code = 'ASC'
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const items = paged.map(toRow)
|
|
175
|
+
const offset = (page - 1) * pageSize
|
|
176
|
+
const [rows, total] = await em.findAndCount(Currency, filter, { orderBy, limit: pageSize, offset })
|
|
177
|
+
const items = rows.map(toRow)
|
|
179
178
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
180
179
|
|
|
181
180
|
return NextResponse.json({ items, total, page, pageSize, totalPages })
|
|
@@ -169,10 +169,9 @@ export async function GET(req: Request) {
|
|
|
169
169
|
orderBy.date = 'DESC'
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const items = paged.map(toRow)
|
|
172
|
+
const offset = (page - 1) * pageSize
|
|
173
|
+
const [rows, total] = await em.findAndCount(ExchangeRate, where, { orderBy, limit: pageSize, offset })
|
|
174
|
+
const items = rows.map(toRow)
|
|
176
175
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
177
176
|
|
|
178
177
|
return NextResponse.json({ items, total, page, pageSize, totalPages })
|