@open-mercato/core 0.10.1-develop.7786.1.g3645046322 → 0.10.1-develop.7792.1.b0ce6289cf
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/audit_logs/api/audit-logs/actions/redo/route.js +4 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js +3 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js.map +3 -3
- package/dist/modules/entities/cli.js +146 -90
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +14 -1
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/warranty_claims/commands/claims.js +13 -4
- package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
- package/dist/modules/warranty_claims/lib/replacementOrderUndo.js +50 -0
- package/dist/modules/warranty_claims/lib/replacementOrderUndo.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +4 -4
- package/src/modules/audit_logs/api/audit-logs/actions/undo/route.ts +3 -6
- package/src/modules/entities/cli.ts +193 -96
- package/src/modules/sales/commands/documents.ts +15 -1
- package/src/modules/warranty_claims/commands/claims.ts +16 -3
- package/src/modules/warranty_claims/lib/replacementOrderUndo.ts +67 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -3,7 +3,8 @@ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
|
3
3
|
import { resolveSingleOrganizationIdOrDeny } from "@open-mercato/shared/lib/auth/organizationScope";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
5
|
import { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
6
|
-
import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
6
|
+
import { isCrudHttpError, translateCrudErrorBody } from "@open-mercato/shared/lib/crud/errors";
|
|
7
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
7
8
|
import { serializeOperationMetadata } from "@open-mercato/shared/lib/commands/operationMetadata";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
@@ -150,7 +151,8 @@ async function POST(req) {
|
|
|
150
151
|
} catch (err) {
|
|
151
152
|
if (isCrudHttpError(err)) {
|
|
152
153
|
logger.warn("Redo rejected", { err });
|
|
153
|
-
|
|
154
|
+
const { translate } = await resolveTranslations();
|
|
155
|
+
return NextResponse.json(translateCrudErrorBody(err.body, translate), { status: err.status });
|
|
154
156
|
}
|
|
155
157
|
const interceptorRejection = getCommandInterceptorHttpRejection(err);
|
|
156
158
|
if (interceptorRejection) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/audit_logs/api/audit-logs/actions/redo/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/shared/lib/auth/organizationScope'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'\nimport type { CommandRuntimeContext, CommandLogMetadata } from '@open-mercato/shared/lib/commands'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport type { AwilixContainer } from 'awilix'\nimport type { ActionLog } from '@open-mercato/core/modules/audit_logs/data/entities'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport { authorizeAuditReplayWithEntityManager } from '@open-mercato/core/modules/audit_logs/lib/replayAuthorization'\nimport {\n actionLogBelongsToAuth,\n resolveCanonicalAuthSubject,\n resolveActionLogActorSubject,\n} from '@open-mercato/core/modules/audit_logs/lib/actorSubject'\n\nconst logger = createLogger('audit_logs').child({ component: 'redo' })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['audit_logs.redo_self'] },\n}\n\ntype RedoRequestBody = {\n logId?: string\n}\n\nconst redoRequestSchema = z.object({\n logId: z.string().min(1).describe('Identifier of the previously undone action log'),\n})\n\nconst redoResponseSchema = z.object({\n ok: z.literal(true),\n logId: z.string().nullable().describe('Identifier of the new redo log entry, if available'),\n undoToken: z.string().nullable().describe('New undo token associated with the redone action'),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const authSubject = resolveCanonicalAuthSubject(auth)\n if (!authSubject) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n\n const body = (await req.json().catch(() => null)) as RedoRequestBody | null\n const logId = typeof body?.logId === 'string' ? body.logId.trim() : ''\n if (!logId) return NextResponse.json({ error: 'Invalid log id' }, { status: 400 })\n\n const container = await createRequestContainer()\n const commandBus = (container.resolve('commandBus') as CommandBus)\n const logs = (container.resolve('actionLogService') as ActionLogService)\n let rbac: RbacService | null = null\n try {\n rbac = (container.resolve('rbacService') as RbacService)\n } catch {\n rbac = null\n }\n\n const { organizationId, scope } = await resolveFeatureCheckContext({ container, auth, request: req })\n let singleOrganizationId: string | null\n try {\n singleOrganizationId = organizationId ?? resolveSingleOrganizationIdOrDeny(scope, auth)\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n const canRedoTenant = rbac\n ? await rbac.userHasAllFeatures(auth.sub, ['audit_logs.redo_tenant'], {\n tenantId: auth.tenantId ?? null,\n organizationId,\n })\n : false\n\n const scopedOrgId = canRedoTenant ? organizationId ?? null : singleOrganizationId\n const log = await logs.findById(logId)\n\n if (!log || log.executionState !== 'undone') {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n const logActorSubject = resolveActionLogActorSubject(log)\n const isLegacyApiKeyOwner = log.actorUserId === authSubject.storageId\n && authSubject.kind === 'api_key'\n && log.contextJson?.actorSubject === undefined\n if (log.actorUserId && !logActorSubject && !isLegacyApiKeyOwner) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n if (log.actorUserId && !actionLogBelongsToAuth(log, auth) && !canRedoTenant) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n // Fail closed on tenant scope: `audit_logs.redo_tenant` only widens scope WITHIN a\n // tenant, never across tenants, so a tenant-scoped target always requires a caller\n // bound to that same tenant. A caller whose tenantId is null (tenant-less global\n // account or unscoped API key) must never redo a tenant-scoped row. Mirrors the\n // hardened undo route (issue #2685, ported in #2931).\n if (log.tenantId && log.tenantId !== (auth.tenantId ?? null)) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n // Tenant-level redoers may redo across organizations within the tenant, so an\n // unresolved (null) caller org is allowed and only an explicit mismatch is rejected.\n // Every other caller must resolve to the target's own organization \u2014 a null caller\n // org must not bypass an org-scoped target (issue #2685, ported in #2931).\n const orgScopeMismatch = canRedoTenant\n ? Boolean(log.organizationId && scopedOrgId && log.organizationId !== scopedOrgId)\n : Boolean(log.organizationId && log.organizationId !== scopedOrgId)\n if (orgScopeMismatch) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n\n const lookupActorId = canRedoTenant\n ? (logActorSubject ?? (isLegacyApiKeyOwner ? authSubject.subject : auth.sub))\n : authSubject.subject\n const latestUndoneOrganizationId = log.organizationId ?? null\n const latestUndone = await logs.latestUndoneForActor(lookupActorId, {\n tenantId: auth.tenantId ?? null,\n organizationId: latestUndoneOrganizationId,\n })\n if (!latestUndone || latestUndone.id !== log.id) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n if (!rbac) return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n const replayRbac = rbac\n\n try {\n const ctx = await createRuntimeContext(container, auth, req)\n ctx.replayTransactionGuard = ({ logEntry, transactionalEm }) =>\n authorizeAuditReplayWithEntityManager(transactionalEm, replayRbac, logEntry, {\n auth,\n organizationId,\n selfFeature: 'audit_logs.redo_self',\n tenantFeature: 'audit_logs.redo_tenant',\n unavailableMessage: 'Redo target not available',\n })\n const contextRecord = log.contextJson && typeof log.contextJson === 'object' ? (log.contextJson as Record<string, unknown>) : null\n const cacheAliasesRaw = Array.isArray(contextRecord?.cacheAliases as unknown[])\n ? (contextRecord!.cacheAliases as unknown[])\n : []\n const cacheAliases = cacheAliasesRaw\n .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)\n .map((value) => value.trim())\n const metadataContext: Record<string, unknown> = {\n historyAction: 'redo',\n sourceLogId: log.id,\n sourceCommandId: log.commandId,\n }\n if (cacheAliases.length) metadataContext.cacheAliases = cacheAliases\n const metadata: CommandLogMetadata = {\n tenantId: log.tenantId,\n organizationId: log.organizationId,\n actorUserId: auth.sub,\n actionLabel: log.actionLabel,\n resourceKind: log.resourceKind,\n resourceId: log.resourceId,\n context: metadataContext,\n }\n const resolvedInput = resolveRedoInput(log.commandPayload, log)\n if (!resolvedInput) {\n return NextResponse.json({ error: 'Redo data unavailable for this action' }, { status: 400 })\n }\n const commandInput = resolvedInput\n const { logEntry, replaySourceFinalized } = await commandBus.execute(log.commandId, {\n input: commandInput,\n ctx,\n metadata,\n redoLogEntry: log,\n })\n if (replaySourceFinalized !== true) await logs.markRedone(log.id)\n const actionLog = asActionLog(logEntry)\n const response = NextResponse.json({\n ok: true,\n logId: actionLog?.id ?? null,\n undoToken: actionLog?.undoToken ?? null,\n })\n if (actionLog?.undoToken && actionLog.id) {\n const createdAt = actionLog.createdAt instanceof Date\n ? actionLog.createdAt.toISOString()\n : (typeof actionLog.createdAt === 'string' ? actionLog.createdAt : new Date().toISOString())\n response.headers.set('x-om-operation', serializeOperationMetadata({\n id: actionLog.id,\n undoToken: actionLog.undoToken,\n commandId: actionLog.commandId ?? log.commandId,\n actionLabel: actionLog.actionLabel ?? log.actionLabel ?? null,\n resourceKind: typeof actionLog.resourceKind === 'string' ? actionLog.resourceKind : log.resourceKind ?? null,\n resourceId: typeof actionLog.resourceId === 'string' ? actionLog.resourceId : log.resourceId ?? null,\n executedAt: createdAt,\n }))\n }\n return response\n } catch (err) {\n // Same contract as undo: a domain refusal carries an already-localized body the\n // operator can act on, so surface it instead of a generic message.\n if (isCrudHttpError(err)) {\n logger.warn('Redo rejected', { err })\n return NextResponse.json(err.body, { status: err.status })\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n logger.error('Redo failed', { err })\n return NextResponse.json({ error: 'Redo failed' }, { status: 400 })\n }\n}\n\nasync function createRuntimeContext(container: AwilixContainer, auth: AuthContext, request: Request): Promise<CommandRuntimeContext> {\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request })\n return {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: scope.selectedId,\n organizationIds: scope.filterIds,\n request,\n }\n}\n\nfunction asActionLog(entry: unknown): ActionLog | null {\n if (!entry || typeof entry !== 'object') return null\n if (typeof (entry as { id?: unknown }).id !== 'string') return null\n return entry as ActionLog\n}\n\nfunction resolveRedoInput(payload: unknown, log: ActionLog): unknown | null {\n if (payload && typeof payload === 'object' && !Array.isArray(payload) && '__redoInput' in payload) {\n const envelope = payload as { __redoInput?: unknown }\n return envelope.__redoInput ?? {}\n }\n const updateFallback = deriveUpdateInput(log)\n if (updateFallback) return updateFallback\n return null\n}\n\nfunction deriveUpdateInput(log: ActionLog): Record<string, unknown> | null {\n if (!log.commandId.endsWith('.update')) return null\n if (!log.resourceId) return null\n const changes = log.changesJson\n if (!changes || typeof changes !== 'object' || Array.isArray(changes)) return { id: log.resourceId }\n const payload: Record<string, unknown> = { id: log.resourceId }\n for (const [key, value] of Object.entries(changes)) {\n if (value && typeof value === 'object' && !Array.isArray(value) && 'to' in value) {\n payload[key] = (value as Record<string, unknown>).to\n } else {\n payload[key] = value\n }\n }\n return payload\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Redo a previously undone action',\n description: 'Replays the command associated with a recently undone action, reapplying the change and issuing a fresh undo token.',\n methods: {\n POST: {\n summary: 'Redo by action log id',\n description:\n 'Redoes the latest undone command owned by the caller. Requires the action to still be eligible for redo within tenant and organization scope.',\n requestBody: {\n contentType: 'application/json',\n schema: redoRequestSchema,\n },\n responses: [\n { status: 200, description: 'Redo executed successfully', schema: redoResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Log not eligible for redo', schema: errorSchema },\n { status: 401, description: 'Authentication required', schema: errorSchema },\n { status: 403, description: 'Redo blocked by scope checks', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,0BAA4C;AACrD,SAAS,yCAAyC;AAClD,SAAS,8BAA8B;AACvC,SAAS,4BAA4B,0CAA0C;AAG/E,SAAS,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/shared/lib/auth/organizationScope'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport { isCrudHttpError, translateCrudErrorBody } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'\nimport type { CommandRuntimeContext, CommandLogMetadata } from '@open-mercato/shared/lib/commands'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport type { AwilixContainer } from 'awilix'\nimport type { ActionLog } from '@open-mercato/core/modules/audit_logs/data/entities'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport { authorizeAuditReplayWithEntityManager } from '@open-mercato/core/modules/audit_logs/lib/replayAuthorization'\nimport {\n actionLogBelongsToAuth,\n resolveCanonicalAuthSubject,\n resolveActionLogActorSubject,\n} from '@open-mercato/core/modules/audit_logs/lib/actorSubject'\n\nconst logger = createLogger('audit_logs').child({ component: 'redo' })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['audit_logs.redo_self'] },\n}\n\ntype RedoRequestBody = {\n logId?: string\n}\n\nconst redoRequestSchema = z.object({\n logId: z.string().min(1).describe('Identifier of the previously undone action log'),\n})\n\nconst redoResponseSchema = z.object({\n ok: z.literal(true),\n logId: z.string().nullable().describe('Identifier of the new redo log entry, if available'),\n undoToken: z.string().nullable().describe('New undo token associated with the redone action'),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const authSubject = resolveCanonicalAuthSubject(auth)\n if (!authSubject) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n\n const body = (await req.json().catch(() => null)) as RedoRequestBody | null\n const logId = typeof body?.logId === 'string' ? body.logId.trim() : ''\n if (!logId) return NextResponse.json({ error: 'Invalid log id' }, { status: 400 })\n\n const container = await createRequestContainer()\n const commandBus = (container.resolve('commandBus') as CommandBus)\n const logs = (container.resolve('actionLogService') as ActionLogService)\n let rbac: RbacService | null = null\n try {\n rbac = (container.resolve('rbacService') as RbacService)\n } catch {\n rbac = null\n }\n\n const { organizationId, scope } = await resolveFeatureCheckContext({ container, auth, request: req })\n let singleOrganizationId: string | null\n try {\n singleOrganizationId = organizationId ?? resolveSingleOrganizationIdOrDeny(scope, auth)\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n const canRedoTenant = rbac\n ? await rbac.userHasAllFeatures(auth.sub, ['audit_logs.redo_tenant'], {\n tenantId: auth.tenantId ?? null,\n organizationId,\n })\n : false\n\n const scopedOrgId = canRedoTenant ? organizationId ?? null : singleOrganizationId\n const log = await logs.findById(logId)\n\n if (!log || log.executionState !== 'undone') {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n const logActorSubject = resolveActionLogActorSubject(log)\n const isLegacyApiKeyOwner = log.actorUserId === authSubject.storageId\n && authSubject.kind === 'api_key'\n && log.contextJson?.actorSubject === undefined\n if (log.actorUserId && !logActorSubject && !isLegacyApiKeyOwner) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n if (log.actorUserId && !actionLogBelongsToAuth(log, auth) && !canRedoTenant) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n // Fail closed on tenant scope: `audit_logs.redo_tenant` only widens scope WITHIN a\n // tenant, never across tenants, so a tenant-scoped target always requires a caller\n // bound to that same tenant. A caller whose tenantId is null (tenant-less global\n // account or unscoped API key) must never redo a tenant-scoped row. Mirrors the\n // hardened undo route (issue #2685, ported in #2931).\n if (log.tenantId && log.tenantId !== (auth.tenantId ?? null)) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n // Tenant-level redoers may redo across organizations within the tenant, so an\n // unresolved (null) caller org is allowed and only an explicit mismatch is rejected.\n // Every other caller must resolve to the target's own organization \u2014 a null caller\n // org must not bypass an org-scoped target (issue #2685, ported in #2931).\n const orgScopeMismatch = canRedoTenant\n ? Boolean(log.organizationId && scopedOrgId && log.organizationId !== scopedOrgId)\n : Boolean(log.organizationId && log.organizationId !== scopedOrgId)\n if (orgScopeMismatch) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n\n const lookupActorId = canRedoTenant\n ? (logActorSubject ?? (isLegacyApiKeyOwner ? authSubject.subject : auth.sub))\n : authSubject.subject\n const latestUndoneOrganizationId = log.organizationId ?? null\n const latestUndone = await logs.latestUndoneForActor(lookupActorId, {\n tenantId: auth.tenantId ?? null,\n organizationId: latestUndoneOrganizationId,\n })\n if (!latestUndone || latestUndone.id !== log.id) {\n return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n }\n if (!rbac) return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })\n const replayRbac = rbac\n\n try {\n const ctx = await createRuntimeContext(container, auth, req)\n ctx.replayTransactionGuard = ({ logEntry, transactionalEm }) =>\n authorizeAuditReplayWithEntityManager(transactionalEm, replayRbac, logEntry, {\n auth,\n organizationId,\n selfFeature: 'audit_logs.redo_self',\n tenantFeature: 'audit_logs.redo_tenant',\n unavailableMessage: 'Redo target not available',\n })\n const contextRecord = log.contextJson && typeof log.contextJson === 'object' ? (log.contextJson as Record<string, unknown>) : null\n const cacheAliasesRaw = Array.isArray(contextRecord?.cacheAliases as unknown[])\n ? (contextRecord!.cacheAliases as unknown[])\n : []\n const cacheAliases = cacheAliasesRaw\n .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)\n .map((value) => value.trim())\n const metadataContext: Record<string, unknown> = {\n historyAction: 'redo',\n sourceLogId: log.id,\n sourceCommandId: log.commandId,\n }\n if (cacheAliases.length) metadataContext.cacheAliases = cacheAliases\n const metadata: CommandLogMetadata = {\n tenantId: log.tenantId,\n organizationId: log.organizationId,\n actorUserId: auth.sub,\n actionLabel: log.actionLabel,\n resourceKind: log.resourceKind,\n resourceId: log.resourceId,\n context: metadataContext,\n }\n const resolvedInput = resolveRedoInput(log.commandPayload, log)\n if (!resolvedInput) {\n return NextResponse.json({ error: 'Redo data unavailable for this action' }, { status: 400 })\n }\n const commandInput = resolvedInput\n const { logEntry, replaySourceFinalized } = await commandBus.execute(log.commandId, {\n input: commandInput,\n ctx,\n metadata,\n redoLogEntry: log,\n })\n if (replaySourceFinalized !== true) await logs.markRedone(log.id)\n const actionLog = asActionLog(logEntry)\n const response = NextResponse.json({\n ok: true,\n logId: actionLog?.id ?? null,\n undoToken: actionLog?.undoToken ?? null,\n })\n if (actionLog?.undoToken && actionLog.id) {\n const createdAt = actionLog.createdAt instanceof Date\n ? actionLog.createdAt.toISOString()\n : (typeof actionLog.createdAt === 'string' ? actionLog.createdAt : new Date().toISOString())\n response.headers.set('x-om-operation', serializeOperationMetadata({\n id: actionLog.id,\n undoToken: actionLog.undoToken,\n commandId: actionLog.commandId ?? log.commandId,\n actionLabel: actionLog.actionLabel ?? log.actionLabel ?? null,\n resourceKind: typeof actionLog.resourceKind === 'string' ? actionLog.resourceKind : log.resourceKind ?? null,\n resourceId: typeof actionLog.resourceId === 'string' ? actionLog.resourceId : log.resourceId ?? null,\n executedAt: createdAt,\n }))\n }\n return response\n } catch (err) {\n if (isCrudHttpError(err)) {\n logger.warn('Redo rejected', { err })\n const { translate } = await resolveTranslations()\n return NextResponse.json(translateCrudErrorBody(err.body, translate), { status: err.status })\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n logger.error('Redo failed', { err })\n return NextResponse.json({ error: 'Redo failed' }, { status: 400 })\n }\n}\n\nasync function createRuntimeContext(container: AwilixContainer, auth: AuthContext, request: Request): Promise<CommandRuntimeContext> {\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request })\n return {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: scope.selectedId,\n organizationIds: scope.filterIds,\n request,\n }\n}\n\nfunction asActionLog(entry: unknown): ActionLog | null {\n if (!entry || typeof entry !== 'object') return null\n if (typeof (entry as { id?: unknown }).id !== 'string') return null\n return entry as ActionLog\n}\n\nfunction resolveRedoInput(payload: unknown, log: ActionLog): unknown | null {\n if (payload && typeof payload === 'object' && !Array.isArray(payload) && '__redoInput' in payload) {\n const envelope = payload as { __redoInput?: unknown }\n return envelope.__redoInput ?? {}\n }\n const updateFallback = deriveUpdateInput(log)\n if (updateFallback) return updateFallback\n return null\n}\n\nfunction deriveUpdateInput(log: ActionLog): Record<string, unknown> | null {\n if (!log.commandId.endsWith('.update')) return null\n if (!log.resourceId) return null\n const changes = log.changesJson\n if (!changes || typeof changes !== 'object' || Array.isArray(changes)) return { id: log.resourceId }\n const payload: Record<string, unknown> = { id: log.resourceId }\n for (const [key, value] of Object.entries(changes)) {\n if (value && typeof value === 'object' && !Array.isArray(value) && 'to' in value) {\n payload[key] = (value as Record<string, unknown>).to\n } else {\n payload[key] = value\n }\n }\n return payload\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Redo a previously undone action',\n description: 'Replays the command associated with a recently undone action, reapplying the change and issuing a fresh undo token.',\n methods: {\n POST: {\n summary: 'Redo by action log id',\n description:\n 'Redoes the latest undone command owned by the caller. Requires the action to still be eligible for redo within tenant and organization scope.',\n requestBody: {\n contentType: 'application/json',\n schema: redoRequestSchema,\n },\n responses: [\n { status: 200, description: 'Redo executed successfully', schema: redoResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Log not eligible for redo', schema: errorSchema },\n { status: 401, description: 'Authentication required', schema: errorSchema },\n { status: 403, description: 'Redo blocked by scope checks', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,0BAA4C;AACrD,SAAS,yCAAyC;AAClD,SAAS,8BAA8B;AACvC,SAAS,4BAA4B,0CAA0C;AAG/E,SAAS,iBAAiB,8BAA8B;AACxD,SAAS,2BAA2B;AAGpC,SAAS,kCAAkC;AAG3C,SAAS,SAAS;AAElB,SAAS,oBAAoB;AAC7B,SAAS,0CAA0C;AACnD,SAAS,6CAA6C;AACtD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,SAAS,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,OAAO,CAAC;AAE9D,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,sBAAsB,EAAE;AACvE;AAMA,MAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS,gDAAgD;AACpF,CAAC;AAED,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,IAAI,EAAE,QAAQ,IAAI;AAAA,EAClB,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,oDAAoD;AAAA,EAC1F,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,kDAAkD;AAC9F,CAAC;AAED,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,OAAO,EAAE,OAAO;AAClB,CAAC;AAED,eAAsB,KAAK,KAAc;AACvC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,KAAM,QAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAC9E,QAAM,cAAc,4BAA4B,IAAI;AACpD,MAAI,CAAC,YAAa,QAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAErF,QAAM,OAAQ,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAC/C,QAAM,QAAQ,OAAO,MAAM,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;AACpE,MAAI,CAAC,MAAO,QAAO,aAAa,KAAK,EAAE,OAAO,iBAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAEjF,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,aAAc,UAAU,QAAQ,YAAY;AAClD,QAAM,OAAQ,UAAU,QAAQ,kBAAkB;AAClD,MAAI,OAA2B;AAC/B,MAAI;AACF,WAAQ,UAAU,QAAQ,aAAa;AAAA,EACzC,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,QAAM,EAAE,gBAAgB,MAAM,IAAI,MAAM,2BAA2B,EAAE,WAAW,MAAM,SAAS,IAAI,CAAC;AACpG,MAAI;AACJ,MAAI;AACF,2BAAuB,kBAAkB,kCAAkC,OAAO,IAAI;AAAA,EACxF,SAAS,KAAK;AACZ,QAAI,gBAAgB,GAAG,EAAG,QAAO,aAAa,KAAK,IAAI,MAAM,EAAE,QAAQ,IAAI,OAAO,CAAC;AACnF,UAAM;AAAA,EACR;AAEA,QAAM,gBAAgB,OAClB,MAAM,KAAK,mBAAmB,KAAK,KAAK,CAAC,wBAAwB,GAAG;AAAA,IAClE,UAAU,KAAK,YAAY;AAAA,IAC3B;AAAA,EACF,CAAC,IACD;AAEJ,QAAM,cAAc,gBAAgB,kBAAkB,OAAO;AAC7D,QAAM,MAAM,MAAM,KAAK,SAAS,KAAK;AAErC,MAAI,CAAC,OAAO,IAAI,mBAAmB,UAAU;AAC3C,WAAO,aAAa,KAAK,EAAE,OAAO,4BAA4B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClF;AACA,QAAM,kBAAkB,6BAA6B,GAAG;AACxD,QAAM,sBAAsB,IAAI,gBAAgB,YAAY,aACvD,YAAY,SAAS,aACrB,IAAI,aAAa,iBAAiB;AACvC,MAAI,IAAI,eAAe,CAAC,mBAAmB,CAAC,qBAAqB;AAC/D,WAAO,aAAa,KAAK,EAAE,OAAO,4BAA4B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClF;AACA,MAAI,IAAI,eAAe,CAAC,uBAAuB,KAAK,IAAI,KAAK,CAAC,eAAe;AAC3E,WAAO,aAAa,KAAK,EAAE,OAAO,4BAA4B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClF;AAMA,MAAI,IAAI,YAAY,IAAI,cAAc,KAAK,YAAY,OAAO;AAC5D,WAAO,aAAa,KAAK,EAAE,OAAO,4BAA4B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClF;AAKA,QAAM,mBAAmB,gBACrB,QAAQ,IAAI,kBAAkB,eAAe,IAAI,mBAAmB,WAAW,IAC/E,QAAQ,IAAI,kBAAkB,IAAI,mBAAmB,WAAW;AACpE,MAAI,kBAAkB;AACpB,WAAO,aAAa,KAAK,EAAE,OAAO,4BAA4B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClF;AAEA,QAAM,gBAAgB,gBACjB,oBAAoB,sBAAsB,YAAY,UAAU,KAAK,OACtE,YAAY;AAChB,QAAM,6BAA6B,IAAI,kBAAkB;AACzD,QAAM,eAAe,MAAM,KAAK,qBAAqB,eAAe;AAAA,IAClE,UAAU,KAAK,YAAY;AAAA,IAC3B,gBAAgB;AAAA,EAClB,CAAC;AACD,MAAI,CAAC,gBAAgB,aAAa,OAAO,IAAI,IAAI;AAC/C,WAAO,aAAa,KAAK,EAAE,OAAO,4BAA4B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClF;AACA,MAAI,CAAC,KAAM,QAAO,aAAa,KAAK,EAAE,OAAO,4BAA4B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAC3F,QAAM,aAAa;AAEnB,MAAI;AACF,UAAM,MAAM,MAAM,qBAAqB,WAAW,MAAM,GAAG;AAC3D,QAAI,yBAAyB,CAAC,EAAE,UAAAA,WAAU,gBAAgB,MACxD,sCAAsC,iBAAiB,YAAYA,WAAU;AAAA,MAC3E;AAAA,MACA;AAAA,MACA,aAAa;AAAA,MACb,eAAe;AAAA,MACf,oBAAoB;AAAA,IACtB,CAAC;AACH,UAAM,gBAAgB,IAAI,eAAe,OAAO,IAAI,gBAAgB,WAAY,IAAI,cAA0C;AAC9H,UAAM,kBAAkB,MAAM,QAAQ,eAAe,YAAyB,IACzE,cAAe,eAChB,CAAC;AACL,UAAM,eAAe,gBAClB,OAAO,CAAC,UAA2B,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,CAAC,EACvF,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC;AAC9B,UAAM,kBAA2C;AAAA,MAC/C,eAAe;AAAA,MACf,aAAa,IAAI;AAAA,MACjB,iBAAiB,IAAI;AAAA,IACvB;AACA,QAAI,aAAa,OAAQ,iBAAgB,eAAe;AACxD,UAAMC,YAA+B;AAAA,MACnC,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,MACpB,aAAa,KAAK;AAAA,MAClB,aAAa,IAAI;AAAA,MACjB,cAAc,IAAI;AAAA,MAClB,YAAY,IAAI;AAAA,MAChB,SAAS;AAAA,IACX;AACA,UAAM,gBAAgB,iBAAiB,IAAI,gBAAgB,GAAG;AAC9D,QAAI,CAAC,eAAe;AAClB,aAAO,aAAa,KAAK,EAAE,OAAO,wCAAwC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC9F;AACA,UAAM,eAAe;AACrB,UAAM,EAAE,UAAU,sBAAsB,IAAI,MAAM,WAAW,QAAQ,IAAI,WAAW;AAAA,MAClF,OAAO;AAAA,MACP;AAAA,MACA,UAAAA;AAAA,MACA,cAAc;AAAA,IAChB,CAAC;AACD,QAAI,0BAA0B,KAAM,OAAM,KAAK,WAAW,IAAI,EAAE;AAChE,UAAM,YAAY,YAAY,QAAQ;AACtC,UAAM,WAAW,aAAa,KAAK;AAAA,MACjC,IAAI;AAAA,MACJ,OAAO,WAAW,MAAM;AAAA,MACxB,WAAW,WAAW,aAAa;AAAA,IACrC,CAAC;AACD,QAAI,WAAW,aAAa,UAAU,IAAI;AACxC,YAAM,YAAY,UAAU,qBAAqB,OAC7C,UAAU,UAAU,YAAY,IAC/B,OAAO,UAAU,cAAc,WAAW,UAAU,aAAY,oBAAI,KAAK,GAAE,YAAY;AAC5F,eAAS,QAAQ,IAAI,kBAAkB,2BAA2B;AAAA,QAChE,IAAI,UAAU;AAAA,QACd,WAAW,UAAU;AAAA,QACrB,WAAW,UAAU,aAAa,IAAI;AAAA,QACtC,aAAa,UAAU,eAAe,IAAI,eAAe;AAAA,QACzD,cAAc,OAAO,UAAU,iBAAiB,WAAW,UAAU,eAAe,IAAI,gBAAgB;AAAA,QACxG,YAAY,OAAO,UAAU,eAAe,WAAW,UAAU,aAAa,IAAI,cAAc;AAAA,QAChG,YAAY;AAAA,MACd,CAAC,CAAC;AAAA,IACJ;AACA,WAAO;AAAA,EACT,SAAS,KAAK;AACZ,QAAI,gBAAgB,GAAG,GAAG;AACxB,aAAO,KAAK,iBAAiB,EAAE,IAAI,CAAC;AACpC,YAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,aAAO,aAAa,KAAK,uBAAuB,IAAI,MAAM,SAAS,GAAG,EAAE,QAAQ,IAAI,OAAO,CAAC;AAAA,IAC9F;AACA,UAAM,uBAAuB,mCAAmC,GAAG;AACnE,QAAI,sBAAsB;AACxB,aAAO,aAAa,KAAK,qBAAqB,MAAM,EAAE,QAAQ,qBAAqB,OAAO,CAAC;AAAA,IAC7F;AACA,WAAO,MAAM,eAAe,EAAE,IAAI,CAAC;AACnC,WAAO,aAAa,KAAK,EAAE,OAAO,cAAc,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACpE;AACF;AAEA,eAAe,qBAAqB,WAA4B,MAAmB,SAAkD;AACnI,QAAM,QAAQ,MAAM,mCAAmC,EAAE,WAAW,MAAM,QAAQ,CAAC;AACnF,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,mBAAmB;AAAA,IACnB,wBAAwB,MAAM;AAAA,IAC9B,iBAAiB,MAAM;AAAA,IACvB;AAAA,EACF;AACF;AAEA,SAAS,YAAY,OAAkC;AACrD,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,MAAI,OAAQ,MAA2B,OAAO,SAAU,QAAO;AAC/D,SAAO;AACT;AAEA,SAAS,iBAAiB,SAAkB,KAAgC;AAC1E,MAAI,WAAW,OAAO,YAAY,YAAY,CAAC,MAAM,QAAQ,OAAO,KAAK,iBAAiB,SAAS;AACjG,UAAM,WAAW;AACjB,WAAO,SAAS,eAAe,CAAC;AAAA,EAClC;AACA,QAAM,iBAAiB,kBAAkB,GAAG;AAC5C,MAAI,eAAgB,QAAO;AAC3B,SAAO;AACT;AAEA,SAAS,kBAAkB,KAAgD;AACzE,MAAI,CAAC,IAAI,UAAU,SAAS,SAAS,EAAG,QAAO;AAC/C,MAAI,CAAC,IAAI,WAAY,QAAO;AAC5B,QAAM,UAAU,IAAI;AACpB,MAAI,CAAC,WAAW,OAAO,YAAY,YAAY,MAAM,QAAQ,OAAO,EAAG,QAAO,EAAE,IAAI,IAAI,WAAW;AACnG,QAAM,UAAmC,EAAE,IAAI,IAAI,WAAW;AAC9D,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AAClD,QAAI,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,KAAK,QAAQ,OAAO;AAChF,cAAQ,GAAG,IAAK,MAAkC;AAAA,IACpD,OAAO;AACL,cAAQ,GAAG,IAAI;AAAA,IACjB;AAAA,EACF;AACA,SAAO;AACT;AAEO,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aACE;AAAA,MACF,aAAa;AAAA,QACX,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,8BAA8B,QAAQ,mBAAmB;AAAA,MACvF;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,6BAA6B,QAAQ,YAAY;AAAA,QAC7E,EAAE,QAAQ,KAAK,aAAa,2BAA2B,QAAQ,YAAY;AAAA,QAC3E,EAAE,QAAQ,KAAK,aAAa,gCAAgC,QAAQ,YAAY;AAAA,MAClF;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": ["logEntry", "metadata"]
|
|
7
7
|
}
|
|
@@ -3,7 +3,7 @@ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
|
3
3
|
import { resolveSingleOrganizationIdOrDeny } from "@open-mercato/shared/lib/auth/organizationScope";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
5
|
import { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
6
|
-
import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
6
|
+
import { isCrudHttpError, translateCrudErrorBody } from "@open-mercato/shared/lib/crud/errors";
|
|
7
7
|
import { getCommandInterceptorHttpRejection } from "@open-mercato/shared/lib/commands/errors";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
@@ -114,7 +114,8 @@ async function POST(req) {
|
|
|
114
114
|
} catch (err) {
|
|
115
115
|
if (isCrudHttpError(err)) {
|
|
116
116
|
logger.warn("Undo rejected", { err, logId: target.id });
|
|
117
|
-
|
|
117
|
+
const { translate: translate2 } = await resolveTranslations();
|
|
118
|
+
return NextResponse.json(translateCrudErrorBody(err.body, translate2), { status: err.status });
|
|
118
119
|
}
|
|
119
120
|
const interceptorRejection = getCommandInterceptorHttpRejection(err);
|
|
120
121
|
if (interceptorRejection) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/audit_logs/api/audit-logs/actions/undo/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/shared/lib/auth/organizationScope'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport type { AwilixContainer } from 'awilix'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { authorizeAuditReplayWithEntityManager } from '@open-mercato/core/modules/audit_logs/lib/replayAuthorization'\nimport {\n actionLogBelongsToAuth,\n resolveCanonicalAuthSubject,\n resolveActionLogActorSubject,\n} from '@open-mercato/core/modules/audit_logs/lib/actorSubject'\n\nconst logger = createLogger('audit_logs').child({ component: 'undo' })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['audit_logs.undo_self'] },\n}\n\ntype UndoRequestBody = {\n undoToken?: string\n}\n\nconst undoRequestSchema = z.object({\n undoToken: z.string().min(1).describe('Undo token issued by the action log entry'),\n})\n\nconst undoResponseSchema = z.object({\n ok: z.literal(true),\n logId: z.string().describe('Identifier of the action log that was undone'),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const authSubject = resolveCanonicalAuthSubject(auth)\n if (!authSubject) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n\n const body = (await req.json().catch(() => null)) as UndoRequestBody | null\n const undoToken = body?.undoToken?.trim()\n if (!undoToken) return NextResponse.json({ error: 'Invalid undo token' }, { status: 400 })\n\n const container = await createRequestContainer()\n const commandBus = (container.resolve('commandBus') as CommandBus)\n const logs = (container.resolve('actionLogService') as ActionLogService)\n let rbac: RbacService | null = null\n try {\n rbac = (container.resolve('rbacService') as RbacService)\n } catch {\n rbac = null\n }\n\n const { organizationId, scope } = await resolveFeatureCheckContext({ container, auth, request: req })\n let singleOrganizationId: string | null\n try {\n singleOrganizationId = organizationId ?? resolveSingleOrganizationIdOrDeny(scope, auth)\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n const canUndoTenant = rbac\n ? await rbac.userHasAllFeatures(auth.sub, ['audit_logs.undo_tenant'], {\n tenantId: auth.tenantId ?? null,\n organizationId,\n })\n : false\n\n const target = await logs.findByUndoToken(undoToken)\n if (!target || target.executionState !== 'done') {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n const targetActorSubject = resolveActionLogActorSubject(target)\n const isLegacyApiKeyOwner = target.actorUserId === authSubject.storageId\n && authSubject.kind === 'api_key'\n && target.contextJson?.actorSubject === undefined\n if (target.actorUserId && !targetActorSubject && !isLegacyApiKeyOwner) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n if (target.actorUserId && !actionLogBelongsToAuth(target, auth) && !canUndoTenant) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n // Fail closed on tenant scope: `audit_logs.undo_tenant` only widens scope WITHIN a\n // tenant, never across tenants, so a tenant-scoped target always requires a caller\n // bound to that same tenant. A caller whose tenantId is null (tenant-less global\n // account or unscoped API key) must never undo a tenant-scoped row (issue #2685).\n if (target.tenantId && target.tenantId !== (auth.tenantId ?? null)) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n const scopedOrgId = canUndoTenant ? organizationId ?? null : singleOrganizationId\n // Tenant-level undoers may undo across organizations within the tenant, so an\n // unresolved (null) caller org is allowed and only an explicit mismatch is rejected.\n // Every other caller must resolve to the target's own organization \u2014 a null caller\n // org must not bypass an org-scoped target (issue #2685).\n const orgScopeMismatch = canUndoTenant\n ? Boolean(target.organizationId && scopedOrgId && target.organizationId !== scopedOrgId)\n : Boolean(target.organizationId && target.organizationId !== scopedOrgId)\n if (orgScopeMismatch) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n\n const lookupActorId = canUndoTenant\n ? (targetActorSubject ?? (isLegacyApiKeyOwner ? authSubject.subject : auth.sub))\n : authSubject.subject\n // Scope the latest-undoable re-lookup to the target row's own organization, not\n // the caller's currently-resolved org. The actor/tenant/org guards above already\n // authorized the caller for this row; reusing the caller's scope here breaks undo\n // for tenant-level rows (organization create/update/delete/reparent log with a\n // null organization_id) whenever the caller resolves to a concrete home org, so\n // the lookup never matches and returns \"Undo token not available\" (issue #2398).\n const lookupOrgId = target.organizationId ?? null\n let latest = null\n if (target.resourceKind || target.resourceId) {\n latest = await logs.latestUndoableForResource({\n actorUserId: lookupActorId,\n tenantId: auth.tenantId ?? null,\n organizationId: lookupOrgId,\n resourceKind: target.resourceKind ?? undefined,\n resourceId: target.resourceId ?? undefined,\n })\n }\n if (!latest) {\n latest = await logs.latestUndoableForActor(lookupActorId, {\n tenantId: auth.tenantId ?? null,\n organizationId: lookupOrgId,\n })\n }\n if (!latest || latest.id !== target.id) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n if (!rbac) return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n const replayRbac = rbac\n\n try {\n const ctx = await createRuntimeContext(container, auth, req)\n ctx.replayTransactionGuard = ({ logEntry, transactionalEm }) =>\n authorizeAuditReplayWithEntityManager(transactionalEm, replayRbac, logEntry, {\n auth,\n organizationId,\n selfFeature: 'audit_logs.undo_self',\n tenantFeature: 'audit_logs.undo_tenant',\n unavailableMessage: 'Undo token not available',\n })\n await commandBus.undo(undoToken, ctx)\n return NextResponse.json({ ok: true, logId: target.id })\n } catch (err) {\n // A command can refuse an undo for a domain reason the operator can act on \u2014 e.g.\n // reverting a role grant would drop a tenant below a protected role's active-holder\n // floor. Those arrive as CrudHttpError with an already-localized body, so pass them\n // through verbatim instead of flattening every failure to a generic message.\n if (isCrudHttpError(err)) {\n logger.warn('Undo rejected', { err, logId: target.id })\n return NextResponse.json(err.body, { status: err.status })\n }\n // A beforeUndo interceptor that blocked with an explicit status is a deliberate business\n // rejection, not an undo failure \u2014 surface its status and message (issue #5045).\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n logger.error('Undo failed', { err })\n const { translate } = await resolveTranslations()\n return NextResponse.json({ error: translate('audit_logs.error.undo_failed', 'Undo failed') }, { status: 400 })\n }\n}\n\nasync function createRuntimeContext(container: AwilixContainer, auth: AuthContext, request: Request): Promise<CommandRuntimeContext> {\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request })\n return {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: scope.selectedId,\n organizationIds: scope.filterIds,\n request,\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Undo a recent action',\n description: 'Executes the undo operation for the most recent undoable action belonging to the caller.',\n methods: {\n POST: {\n summary: 'Undo action by token',\n description:\n 'Replays the undo handler registered for a command. The provided undo token must match the latest undoable log entry accessible to the caller.',\n requestBody: {\n contentType: 'application/json',\n schema: undoRequestSchema,\n },\n responses: [\n { status: 200, description: 'Undo applied successfully', schema: undoResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid or unavailable undo token', schema: errorSchema },\n { status: 401, description: 'Authentication required', schema: errorSchema },\n { status: 403, description: 'Undo blocked by organization or tenant scope', schema: errorSchema },\n {\n status: 422,\n description:\n 'Undo deliberately blocked by a beforeUndo command interceptor. The interceptor chooses the status (any 4xx/5xx) and may replace the body.',\n schema: errorSchema,\n },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,0BAA4C;AACrD,SAAS,yCAAyC;AAClD,SAAS,8BAA8B;AACvC,SAAS,4BAA4B,0CAA0C;AAG/E,SAAS,
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/shared/lib/auth/organizationScope'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport { isCrudHttpError, translateCrudErrorBody } from '@open-mercato/shared/lib/crud/errors'\nimport { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport type { AwilixContainer } from 'awilix'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { authorizeAuditReplayWithEntityManager } from '@open-mercato/core/modules/audit_logs/lib/replayAuthorization'\nimport {\n actionLogBelongsToAuth,\n resolveCanonicalAuthSubject,\n resolveActionLogActorSubject,\n} from '@open-mercato/core/modules/audit_logs/lib/actorSubject'\n\nconst logger = createLogger('audit_logs').child({ component: 'undo' })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['audit_logs.undo_self'] },\n}\n\ntype UndoRequestBody = {\n undoToken?: string\n}\n\nconst undoRequestSchema = z.object({\n undoToken: z.string().min(1).describe('Undo token issued by the action log entry'),\n})\n\nconst undoResponseSchema = z.object({\n ok: z.literal(true),\n logId: z.string().describe('Identifier of the action log that was undone'),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const authSubject = resolveCanonicalAuthSubject(auth)\n if (!authSubject) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n\n const body = (await req.json().catch(() => null)) as UndoRequestBody | null\n const undoToken = body?.undoToken?.trim()\n if (!undoToken) return NextResponse.json({ error: 'Invalid undo token' }, { status: 400 })\n\n const container = await createRequestContainer()\n const commandBus = (container.resolve('commandBus') as CommandBus)\n const logs = (container.resolve('actionLogService') as ActionLogService)\n let rbac: RbacService | null = null\n try {\n rbac = (container.resolve('rbacService') as RbacService)\n } catch {\n rbac = null\n }\n\n const { organizationId, scope } = await resolveFeatureCheckContext({ container, auth, request: req })\n let singleOrganizationId: string | null\n try {\n singleOrganizationId = organizationId ?? resolveSingleOrganizationIdOrDeny(scope, auth)\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n const canUndoTenant = rbac\n ? await rbac.userHasAllFeatures(auth.sub, ['audit_logs.undo_tenant'], {\n tenantId: auth.tenantId ?? null,\n organizationId,\n })\n : false\n\n const target = await logs.findByUndoToken(undoToken)\n if (!target || target.executionState !== 'done') {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n const targetActorSubject = resolveActionLogActorSubject(target)\n const isLegacyApiKeyOwner = target.actorUserId === authSubject.storageId\n && authSubject.kind === 'api_key'\n && target.contextJson?.actorSubject === undefined\n if (target.actorUserId && !targetActorSubject && !isLegacyApiKeyOwner) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n if (target.actorUserId && !actionLogBelongsToAuth(target, auth) && !canUndoTenant) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n // Fail closed on tenant scope: `audit_logs.undo_tenant` only widens scope WITHIN a\n // tenant, never across tenants, so a tenant-scoped target always requires a caller\n // bound to that same tenant. A caller whose tenantId is null (tenant-less global\n // account or unscoped API key) must never undo a tenant-scoped row (issue #2685).\n if (target.tenantId && target.tenantId !== (auth.tenantId ?? null)) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n const scopedOrgId = canUndoTenant ? organizationId ?? null : singleOrganizationId\n // Tenant-level undoers may undo across organizations within the tenant, so an\n // unresolved (null) caller org is allowed and only an explicit mismatch is rejected.\n // Every other caller must resolve to the target's own organization \u2014 a null caller\n // org must not bypass an org-scoped target (issue #2685).\n const orgScopeMismatch = canUndoTenant\n ? Boolean(target.organizationId && scopedOrgId && target.organizationId !== scopedOrgId)\n : Boolean(target.organizationId && target.organizationId !== scopedOrgId)\n if (orgScopeMismatch) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n\n const lookupActorId = canUndoTenant\n ? (targetActorSubject ?? (isLegacyApiKeyOwner ? authSubject.subject : auth.sub))\n : authSubject.subject\n // Scope the latest-undoable re-lookup to the target row's own organization, not\n // the caller's currently-resolved org. The actor/tenant/org guards above already\n // authorized the caller for this row; reusing the caller's scope here breaks undo\n // for tenant-level rows (organization create/update/delete/reparent log with a\n // null organization_id) whenever the caller resolves to a concrete home org, so\n // the lookup never matches and returns \"Undo token not available\" (issue #2398).\n const lookupOrgId = target.organizationId ?? null\n let latest = null\n if (target.resourceKind || target.resourceId) {\n latest = await logs.latestUndoableForResource({\n actorUserId: lookupActorId,\n tenantId: auth.tenantId ?? null,\n organizationId: lookupOrgId,\n resourceKind: target.resourceKind ?? undefined,\n resourceId: target.resourceId ?? undefined,\n })\n }\n if (!latest) {\n latest = await logs.latestUndoableForActor(lookupActorId, {\n tenantId: auth.tenantId ?? null,\n organizationId: lookupOrgId,\n })\n }\n if (!latest || latest.id !== target.id) {\n return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n }\n if (!rbac) return NextResponse.json({ error: 'Undo token not available' }, { status: 400 })\n const replayRbac = rbac\n\n try {\n const ctx = await createRuntimeContext(container, auth, req)\n ctx.replayTransactionGuard = ({ logEntry, transactionalEm }) =>\n authorizeAuditReplayWithEntityManager(transactionalEm, replayRbac, logEntry, {\n auth,\n organizationId,\n selfFeature: 'audit_logs.undo_self',\n tenantFeature: 'audit_logs.undo_tenant',\n unavailableMessage: 'Undo token not available',\n })\n await commandBus.undo(undoToken, ctx)\n return NextResponse.json({ ok: true, logId: target.id })\n } catch (err) {\n if (isCrudHttpError(err)) {\n logger.warn('Undo rejected', { err, logId: target.id })\n const { translate } = await resolveTranslations()\n return NextResponse.json(translateCrudErrorBody(err.body, translate), { status: err.status })\n }\n // A beforeUndo interceptor that blocked with an explicit status is a deliberate business\n // rejection, not an undo failure \u2014 surface its status and message (issue #5045).\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n logger.error('Undo failed', { err })\n const { translate } = await resolveTranslations()\n return NextResponse.json({ error: translate('audit_logs.error.undo_failed', 'Undo failed') }, { status: 400 })\n }\n}\n\nasync function createRuntimeContext(container: AwilixContainer, auth: AuthContext, request: Request): Promise<CommandRuntimeContext> {\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request })\n return {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: scope.selectedId,\n organizationIds: scope.filterIds,\n request,\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Undo a recent action',\n description: 'Executes the undo operation for the most recent undoable action belonging to the caller.',\n methods: {\n POST: {\n summary: 'Undo action by token',\n description:\n 'Replays the undo handler registered for a command. The provided undo token must match the latest undoable log entry accessible to the caller.',\n requestBody: {\n contentType: 'application/json',\n schema: undoRequestSchema,\n },\n responses: [\n { status: 200, description: 'Undo applied successfully', schema: undoResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid or unavailable undo token', schema: errorSchema },\n { status: 401, description: 'Authentication required', schema: errorSchema },\n { status: 403, description: 'Undo blocked by organization or tenant scope', schema: errorSchema },\n {\n status: 422,\n description:\n 'Undo deliberately blocked by a beforeUndo command interceptor. The interceptor chooses the status (any 4xx/5xx) and may replace the body.',\n schema: errorSchema,\n },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["translate"]
|
|
7
7
|
}
|
|
@@ -289,6 +289,10 @@ const seedEncryptionMaps = {
|
|
|
289
289
|
function normalizeKeyInput(value) {
|
|
290
290
|
return value.trim().replace(/(?:^['"]|['"]$)/g, "");
|
|
291
291
|
}
|
|
292
|
+
const OLD_ENCRYPTION_KEY_ENV = "TENANT_DATA_ENCRYPTION_OLD_KEY";
|
|
293
|
+
function failCli(message) {
|
|
294
|
+
throw new Error(message);
|
|
295
|
+
}
|
|
292
296
|
class DerivedKeyKmsService {
|
|
293
297
|
constructor(secret) {
|
|
294
298
|
this.root = crypto.createHash("sha256").update(normalizeKeyInput(secret)).digest();
|
|
@@ -310,10 +314,6 @@ class DerivedKeyKmsService {
|
|
|
310
314
|
return this.getTenantDek(tenantId);
|
|
311
315
|
}
|
|
312
316
|
}
|
|
313
|
-
function fingerprintDek(dek) {
|
|
314
|
-
if (!dek?.key) return null;
|
|
315
|
-
return crypto.createHash("sha256").update(dek.key).digest("hex").slice(0, 12);
|
|
316
|
-
}
|
|
317
317
|
function decryptWithOldKey(payload, dek) {
|
|
318
318
|
if (!dek?.key) return null;
|
|
319
319
|
return decryptWithAesGcm(payload, dek.key);
|
|
@@ -342,19 +342,61 @@ function buildEntityMetaRegistry(em) {
|
|
|
342
342
|
}
|
|
343
343
|
return metaByEntityId;
|
|
344
344
|
}
|
|
345
|
-
function
|
|
346
|
-
}) {
|
|
345
|
+
function classifyEncryptionMap(map, metaByEntityId) {
|
|
347
346
|
const entityId = String(map.entityId);
|
|
347
|
+
const fields = Array.isArray(map.fieldsJson) ? map.fieldsJson : [];
|
|
348
|
+
if (!fields.length) {
|
|
349
|
+
return { status: "nothing-to-process", reason: "the map declares no encrypted fields" };
|
|
350
|
+
}
|
|
351
|
+
const tenantId = map.tenantId ? String(map.tenantId) : null;
|
|
352
|
+
if (!tenantId) {
|
|
353
|
+
return {
|
|
354
|
+
status: "nothing-to-process",
|
|
355
|
+
reason: 'the map has no tenant_id \u2014 use "mercato entities backfill-system-encryption" for system-scoped records'
|
|
356
|
+
};
|
|
357
|
+
}
|
|
348
358
|
const meta = metaByEntityId.get(entityId);
|
|
349
359
|
if (!meta) {
|
|
350
|
-
|
|
351
|
-
|
|
360
|
+
return {
|
|
361
|
+
status: "unresolvable",
|
|
362
|
+
reason: "entity metadata is not registered in this app \u2014 is the module that owns it still enabled?"
|
|
363
|
+
};
|
|
352
364
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
365
|
+
if (!meta.tableName) {
|
|
366
|
+
return { status: "unresolvable", reason: "table metadata is missing" };
|
|
367
|
+
}
|
|
368
|
+
for (const rule of fields) {
|
|
369
|
+
if (!resolveProperty(meta, rule.field).columnName) {
|
|
370
|
+
return { status: "unresolvable", reason: `mapped field "${rule.field}" was not found` };
|
|
371
|
+
}
|
|
372
|
+
if (rule.hashField && !resolveProperty(meta, rule.hashField).columnName) {
|
|
373
|
+
return { status: "unresolvable", reason: `hash field "${rule.hashField}" was not found` };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { status: "ready", mapMeta: { entityId, meta, fields, tenantId } };
|
|
377
|
+
}
|
|
378
|
+
function resolveMapsOrFail(maps, metaByEntityId) {
|
|
379
|
+
const resolved = [];
|
|
380
|
+
const unresolvable = [];
|
|
381
|
+
for (const map of maps) {
|
|
382
|
+
const resolution = classifyEncryptionMap(map, metaByEntityId);
|
|
383
|
+
if (resolution.status === "ready") {
|
|
384
|
+
resolved.push({ map, mapMeta: resolution.mapMeta });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (resolution.status === "nothing-to-process") {
|
|
388
|
+
console.warn(`Skipping ${String(map.entityId)}: ${resolution.reason}.`);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
unresolvable.push(` ${String(map.entityId)} (encryption_maps.id=${String(map.id)}): ${resolution.reason}`);
|
|
392
|
+
}
|
|
393
|
+
if (unresolvable.length) {
|
|
394
|
+
failCli(
|
|
395
|
+
`${unresolvable.length} encryption map(s) in the selected scope cannot be processed, so this command would have left their ciphertext untouched while reporting success. No rows were changed.
|
|
396
|
+
` + unresolvable.join("\n") + "\nResolve each one before re-running: re-enable the module that owns the entity, or \u2014 once the entity's data is confirmed gone \u2014 soft-delete the stale map row (UPDATE encryption_maps SET deleted_at = now() WHERE id = '\u2026')."
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return resolved;
|
|
358
400
|
}
|
|
359
401
|
function formatValueForColumn(prop, value) {
|
|
360
402
|
if (value === null || value === void 0) return value;
|
|
@@ -370,31 +412,38 @@ const rotateEncryptionKey = {
|
|
|
370
412
|
const args = parseArgs(rest);
|
|
371
413
|
const tenantIdArg = args.tenant || args.tenantId || null;
|
|
372
414
|
const organizationIdArg = args.org || args.organization || args.organizationId || null;
|
|
373
|
-
const
|
|
415
|
+
const oldKeyArgument = args["old-key"] || args.oldKey || null;
|
|
416
|
+
const oldKeyEnvironment = process.env[OLD_ENCRYPTION_KEY_ENV] ? normalizeKeyInput(process.env[OLD_ENCRYPTION_KEY_ENV]) : null;
|
|
417
|
+
if (oldKeyArgument && oldKeyEnvironment && normalizeKeyInput(oldKeyArgument) !== oldKeyEnvironment) {
|
|
418
|
+
failCli(`--old-key and ${OLD_ENCRYPTION_KEY_ENV} contain different values. Remove --old-key and retry.`);
|
|
419
|
+
}
|
|
420
|
+
if (oldKeyArgument) {
|
|
421
|
+
console.warn(
|
|
422
|
+
`Passing key material through --old-key can expose it in shell history and process listings. Set ${OLD_ENCRYPTION_KEY_ENV} through your secret manager instead.`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const oldKey = oldKeyEnvironment || oldKeyArgument;
|
|
374
426
|
const dryRun = Boolean(args["dry-run"] || args.dry);
|
|
375
427
|
const debug = Boolean(args.debug);
|
|
376
428
|
const rotate = Boolean(oldKey);
|
|
377
429
|
if (rotate && !tenantIdArg) {
|
|
378
430
|
console.warn(
|
|
379
|
-
"\u26A0\uFE0F Rotating with
|
|
431
|
+
"\u26A0\uFE0F Rotating with one previous key across all tenants. A single previous key should normally target one tenant; consider --tenant."
|
|
380
432
|
);
|
|
381
433
|
}
|
|
382
434
|
if (!isTenantDataEncryptionEnabled()) {
|
|
383
|
-
|
|
384
|
-
return;
|
|
435
|
+
failCli("TENANT_DATA_ENCRYPTION is disabled; aborting.");
|
|
385
436
|
}
|
|
386
437
|
const { resolve } = await createRequestContainer();
|
|
387
438
|
const em = resolve("em");
|
|
388
439
|
const conn = em?.getConnection?.();
|
|
389
440
|
if (!conn || typeof conn.execute !== "function") {
|
|
390
|
-
|
|
391
|
-
return;
|
|
441
|
+
failCli("Unable to access raw connection; aborting.");
|
|
392
442
|
}
|
|
393
443
|
const encryptionService = new TenantDataEncryptionService(em, { kms: createKmsService() });
|
|
394
444
|
const oldKms = rotate && oldKey ? new DerivedKeyKmsService(oldKey) : null;
|
|
395
445
|
if (!encryptionService.isEnabled()) {
|
|
396
|
-
|
|
397
|
-
return;
|
|
446
|
+
failCli("Encryption service is not enabled (KMS unhealthy or no DEK). Aborting.");
|
|
398
447
|
}
|
|
399
448
|
if (debug) {
|
|
400
449
|
console.log("[rotate-encryption-key]", {
|
|
@@ -403,18 +452,6 @@ const rotateEncryptionKey = {
|
|
|
403
452
|
tenantId: tenantIdArg ?? null,
|
|
404
453
|
organizationId: organizationIdArg ?? null
|
|
405
454
|
});
|
|
406
|
-
if (tenantIdArg) {
|
|
407
|
-
const [oldDek, newDek] = await Promise.all([
|
|
408
|
-
oldKms?.getTenantDek(tenantIdArg) ?? Promise.resolve(null),
|
|
409
|
-
encryptionService.getDek(tenantIdArg)
|
|
410
|
-
]);
|
|
411
|
-
console.log("[rotate-encryption-key] dek fingerprints", {
|
|
412
|
-
oldKey: fingerprintDek(oldDek),
|
|
413
|
-
currentKey: fingerprintDek(newDek)
|
|
414
|
-
});
|
|
415
|
-
} else {
|
|
416
|
-
console.log("[rotate-encryption-key] dek fingerprints skipped (no tenantId)");
|
|
417
|
-
}
|
|
418
455
|
}
|
|
419
456
|
const metaByEntityId = buildEntityMetaRegistry(em);
|
|
420
457
|
const where = { deletedAt: null };
|
|
@@ -429,6 +466,7 @@ const rotateEncryptionKey = {
|
|
|
429
466
|
);
|
|
430
467
|
return false;
|
|
431
468
|
});
|
|
469
|
+
const resolvedMaps = resolveMapsOrFail(maps, metaByEntityId);
|
|
432
470
|
const customDocuments = await rotateCustomEntityDocuments(em, {
|
|
433
471
|
encryptionService,
|
|
434
472
|
oldKms,
|
|
@@ -532,7 +570,7 @@ const rotateEncryptionKey = {
|
|
|
532
570
|
if (rule.hashField) delete payload[rule.hashField];
|
|
533
571
|
unrecoverableRows.push({ entityId, field: rule.field, rowId: row[pk] });
|
|
534
572
|
console.warn(
|
|
535
|
-
`Skipping ${entityId}.${rule.field} for row ${row[pk]}: its ciphertext opens under neither
|
|
573
|
+
`Skipping ${entityId}.${rule.field} for row ${row[pk]}: its ciphertext opens under neither the previous nor the current tenant key. Re-run with the key that sealed it.`
|
|
536
574
|
);
|
|
537
575
|
}
|
|
538
576
|
}
|
|
@@ -586,9 +624,7 @@ const rotateEncryptionKey = {
|
|
|
586
624
|
return updated;
|
|
587
625
|
};
|
|
588
626
|
let total = 0;
|
|
589
|
-
for (const map of
|
|
590
|
-
const mapMeta = resolveMapMeta(map, metaByEntityId, console.warn);
|
|
591
|
-
if (!mapMeta) continue;
|
|
627
|
+
for (const { map, mapMeta } of resolvedMaps) {
|
|
592
628
|
const { entityId, meta, fields, tenantId } = mapMeta;
|
|
593
629
|
const scopes = await resolveScopes(tenantId, map.organizationId ? String(map.organizationId) : null);
|
|
594
630
|
for (const scope of scopes) {
|
|
@@ -610,7 +646,7 @@ const rotateEncryptionKey = {
|
|
|
610
646
|
const maxListed = 20;
|
|
611
647
|
console.log(
|
|
612
648
|
`
|
|
613
|
-
\u26A0\uFE0F ${unrecoverableRows.length} field(s) could not be rotated \u2014 their ciphertext opens under neither
|
|
649
|
+
\u26A0\uFE0F ${unrecoverableRows.length} field(s) could not be rotated \u2014 their ciphertext opens under neither the previous nor the current tenant key, so they were left untouched. See "Key management" in apps/docs/docs/architecture/data-encryption.mdx for how to resolve these manually.`
|
|
614
650
|
);
|
|
615
651
|
for (const row of unrecoverableRows.slice(0, maxListed)) {
|
|
616
652
|
console.log(` - ${row.entityId}.${row.field} row=${String(row.rowId)}`);
|
|
@@ -618,6 +654,9 @@ const rotateEncryptionKey = {
|
|
|
618
654
|
if (unrecoverableRows.length > maxListed) {
|
|
619
655
|
console.log(` ... and ${unrecoverableRows.length - maxListed} more`);
|
|
620
656
|
}
|
|
657
|
+
failCli(
|
|
658
|
+
`${unrecoverableRows.length} encrypted field(s) could not be rotated. The command left them untouched and did not complete successfully.`
|
|
659
|
+
);
|
|
621
660
|
}
|
|
622
661
|
}
|
|
623
662
|
};
|
|
@@ -636,29 +675,24 @@ const decryptDatabase = {
|
|
|
636
675
|
const sleepMs = Math.max(0, parseInt(String(args["sleep-ms"] || args.sleepMs || "0"), 10) || 0);
|
|
637
676
|
const debug = Boolean(args.debug);
|
|
638
677
|
if (!tenantIdArg) {
|
|
639
|
-
|
|
640
|
-
return;
|
|
678
|
+
failCli("--tenant <uuid> is required.");
|
|
641
679
|
}
|
|
642
680
|
if (!checkMode) {
|
|
643
681
|
if (!confirm) {
|
|
644
|
-
|
|
645
|
-
return;
|
|
682
|
+
failCli("--confirm <tenantUuid> is required (safety gate). Pass the exact tenant UUID to confirm the operation.");
|
|
646
683
|
}
|
|
647
684
|
if (confirm !== tenantIdArg) {
|
|
648
|
-
|
|
649
|
-
return;
|
|
685
|
+
failCli(`--confirm value "${confirm}" does not match --tenant "${tenantIdArg}". Aborting.`);
|
|
650
686
|
}
|
|
651
687
|
}
|
|
652
688
|
const { resolve } = await createRequestContainer();
|
|
653
689
|
const em = resolve("em");
|
|
654
690
|
const conn = em?.getConnection?.();
|
|
655
691
|
if (!conn || typeof conn.execute !== "function") {
|
|
656
|
-
|
|
657
|
-
return;
|
|
692
|
+
failCli("Unable to access raw database connection; aborting.");
|
|
658
693
|
}
|
|
659
694
|
if (!checkMode && !isTenantDataEncryptionEnabled()) {
|
|
660
|
-
|
|
661
|
-
return;
|
|
695
|
+
failCli("TENANT_DATA_ENCRYPTION is disabled; aborting. Data may already be decrypted.");
|
|
662
696
|
}
|
|
663
697
|
const metaByEntityId = buildEntityMetaRegistry(em);
|
|
664
698
|
const resolveDecryptScopes = async (tenantId, organizationId) => {
|
|
@@ -690,6 +724,7 @@ const decryptDatabase = {
|
|
|
690
724
|
console.log("No active encryption maps found for the selected scope.");
|
|
691
725
|
return;
|
|
692
726
|
}
|
|
727
|
+
const resolvedMaps = resolveMapsOrFail(maps, metaByEntityId);
|
|
693
728
|
const kms = createKmsService();
|
|
694
729
|
const dekCache = /* @__PURE__ */ new Map();
|
|
695
730
|
const getDek = async (tenantId) => {
|
|
@@ -704,16 +739,19 @@ const decryptDatabase = {
|
|
|
704
739
|
console.log(`Active EncryptionMap records for scope: ${maps.length}`);
|
|
705
740
|
let encryptedCandidatesSampled = 0;
|
|
706
741
|
let malformedPayloadCountSampled = 0;
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
742
|
+
const uninspectableEntityIds = [];
|
|
743
|
+
const failedSampleScopes = [];
|
|
744
|
+
for (const { map, mapMeta } of resolvedMaps) {
|
|
710
745
|
const { entityId, meta, fields, tenantId } = mapMeta;
|
|
711
746
|
const dek = await getDek(tenantId).catch(() => null);
|
|
712
|
-
if (!dek)
|
|
747
|
+
if (!dek) {
|
|
748
|
+
console.warn(`\u26A0 No DEK available for tenant ${tenantId}; cannot inspect ${entityId}.`);
|
|
749
|
+
uninspectableEntityIds.push(entityId);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
713
752
|
const scopes = await resolveDecryptScopes(tenantId, map.organizationId ? String(map.organizationId) : null);
|
|
714
753
|
const pk = Array.isArray(meta?.primaryKeys) && meta.primaryKeys.length ? meta.primaryKeys[0] : "id";
|
|
715
|
-
const tableName = meta
|
|
716
|
-
if (!tableName) continue;
|
|
754
|
+
const tableName = meta.tableName;
|
|
717
755
|
const schema = meta?.schema;
|
|
718
756
|
const qualifiedTable = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`;
|
|
719
757
|
const fieldCols = fields.flatMap((f) => {
|
|
@@ -722,10 +760,19 @@ const decryptDatabase = {
|
|
|
722
760
|
});
|
|
723
761
|
const colList = Array.from(/* @__PURE__ */ new Set([pk, ...fieldCols]));
|
|
724
762
|
for (const scope of scopes) {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
763
|
+
let sampleRows;
|
|
764
|
+
try {
|
|
765
|
+
sampleRows = await conn.execute(
|
|
766
|
+
`SELECT ${colList.map((c) => `"${c}"`).join(", ")} FROM ${qualifiedTable} WHERE tenant_id = ? AND organization_id IS NOT DISTINCT FROM ? LIMIT 100`,
|
|
767
|
+
[scope.tenantId, scope.organizationId]
|
|
768
|
+
);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
const scopeLabel = scope.organizationId ?? "global";
|
|
771
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
772
|
+
console.warn(`\u26A0 Could not sample ${entityId} for organization ${scopeLabel}: ${errorMessage}`);
|
|
773
|
+
failedSampleScopes.push(`${entityId} (organization_id=${scopeLabel})`);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
729
776
|
for (const row of Array.isArray(sampleRows) ? sampleRows : []) {
|
|
730
777
|
let rowHasEncrypted = false;
|
|
731
778
|
for (const fieldRule of fields) {
|
|
@@ -754,6 +801,13 @@ const decryptDatabase = {
|
|
|
754
801
|
console.log(`malformed payloads (sampled): ${malformedPayloadCountSampled}`);
|
|
755
802
|
}
|
|
756
803
|
console.log("not a proof of absence \u2014 run full command + rerun --check to confirm");
|
|
804
|
+
if (uninspectableEntityIds.length || failedSampleScopes.length) {
|
|
805
|
+
const unavailableDekSummary = uninspectableEntityIds.length ? ` No DEK was available for ${uninspectableEntityIds.join(", ")}.` : "";
|
|
806
|
+
const failedSampleSummary = failedSampleScopes.length ? ` Sample queries failed for ${failedSampleScopes.join(", ")}.` : "";
|
|
807
|
+
failCli(
|
|
808
|
+
`Encryption check incomplete.${unavailableDekSummary}${failedSampleSummary} The counts above cover only successfully inspected scopes.`
|
|
809
|
+
);
|
|
810
|
+
}
|
|
757
811
|
return;
|
|
758
812
|
}
|
|
759
813
|
let totalRowsFetched = 0;
|
|
@@ -763,21 +817,20 @@ const decryptDatabase = {
|
|
|
763
817
|
let totalMalformedPayloadCount = 0;
|
|
764
818
|
const malformedByLocation = /* @__PURE__ */ new Map();
|
|
765
819
|
let totalEntitiesProcessed = 0;
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
if (
|
|
769
|
-
const
|
|
770
|
-
const dek = await getDek(tenantId);
|
|
820
|
+
const deksByTenantId = /* @__PURE__ */ new Map();
|
|
821
|
+
for (const { mapMeta } of resolvedMaps) {
|
|
822
|
+
if (deksByTenantId.has(mapMeta.tenantId)) continue;
|
|
823
|
+
const dek = await getDek(mapMeta.tenantId);
|
|
771
824
|
if (!dek) {
|
|
772
|
-
|
|
773
|
-
continue;
|
|
825
|
+
failCli(`No DEK available for tenant ${mapMeta.tenantId}. No rows were changed.`);
|
|
774
826
|
}
|
|
827
|
+
deksByTenantId.set(mapMeta.tenantId, dek);
|
|
828
|
+
}
|
|
829
|
+
for (const { map, mapMeta } of resolvedMaps) {
|
|
830
|
+
const { entityId, meta, fields, tenantId } = mapMeta;
|
|
831
|
+
const dek = deksByTenantId.get(tenantId);
|
|
775
832
|
const pk = Array.isArray(meta?.primaryKeys) && meta.primaryKeys.length ? meta.primaryKeys[0] : "id";
|
|
776
|
-
const tableName = meta
|
|
777
|
-
if (!tableName) {
|
|
778
|
-
console.warn(`Skipping ${entityId}: table name not found.`);
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
833
|
+
const tableName = meta.tableName;
|
|
781
834
|
const schema = meta?.schema;
|
|
782
835
|
const qualifiedTable = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`;
|
|
783
836
|
const fieldCols = fields.flatMap((f) => {
|
|
@@ -900,26 +953,6 @@ const decryptDatabase = {
|
|
|
900
953
|
totalMalformedPayloadCount += scopeMalformedCount;
|
|
901
954
|
}
|
|
902
955
|
}
|
|
903
|
-
if (deactivateMaps && !dryRun) {
|
|
904
|
-
let deactivateSql = `UPDATE encryption_maps SET is_active = false, deleted_at = now() WHERE tenant_id = ? AND deleted_at IS NULL`;
|
|
905
|
-
const deactivateParams = [tenantIdArg];
|
|
906
|
-
if (organizationIdArg) {
|
|
907
|
-
deactivateSql += ` AND (organization_id = ? OR organization_id IS NULL)`;
|
|
908
|
-
deactivateParams.push(organizationIdArg);
|
|
909
|
-
}
|
|
910
|
-
if (entityIdArg) {
|
|
911
|
-
deactivateSql += ` AND entity_id = ?`;
|
|
912
|
-
deactivateParams.push(entityIdArg);
|
|
913
|
-
}
|
|
914
|
-
await conn.execute(deactivateSql, deactivateParams);
|
|
915
|
-
console.warn("\u26A0 Restart all application replicas \u2014 in-process map caches may still be active.");
|
|
916
|
-
if (isTenantDataEncryptionEnabled()) {
|
|
917
|
-
console.warn("\u26A0 Env TENANT_DATA_ENCRYPTION is still true \u2014 new writes will be re-encrypted until env is updated and replicas restarted.");
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
if (deactivateMaps && dryRun) {
|
|
921
|
-
console.log(`[dry-run] Would deactivate ${maps.length} EncryptionMap record(s).`);
|
|
922
|
-
}
|
|
923
956
|
const prefix = dryRun ? "[dry-run] " : "";
|
|
924
957
|
console.log(`
|
|
925
958
|
${prefix}Decryption summary:`);
|
|
@@ -941,6 +974,29 @@ ${prefix}Decryption summary:`);
|
|
|
941
974
|
console.log(` ${loc}: ${count}`);
|
|
942
975
|
}
|
|
943
976
|
}
|
|
977
|
+
failCli(
|
|
978
|
+
`${totalMalformedPayloadCount} field value(s) could not be decrypted because their ciphertext was malformed. Encryption maps were left active.`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
if (deactivateMaps && !dryRun) {
|
|
982
|
+
let deactivateSql = `UPDATE encryption_maps SET is_active = false, deleted_at = now() WHERE tenant_id = ? AND deleted_at IS NULL`;
|
|
983
|
+
const deactivateParams = [tenantIdArg];
|
|
984
|
+
if (organizationIdArg) {
|
|
985
|
+
deactivateSql += ` AND (organization_id = ? OR organization_id IS NULL)`;
|
|
986
|
+
deactivateParams.push(organizationIdArg);
|
|
987
|
+
}
|
|
988
|
+
if (entityIdArg) {
|
|
989
|
+
deactivateSql += ` AND entity_id = ?`;
|
|
990
|
+
deactivateParams.push(entityIdArg);
|
|
991
|
+
}
|
|
992
|
+
await conn.execute(deactivateSql, deactivateParams);
|
|
993
|
+
console.warn("\u26A0 Restart all application replicas \u2014 in-process map caches may still be active.");
|
|
994
|
+
if (isTenantDataEncryptionEnabled()) {
|
|
995
|
+
console.warn("\u26A0 Env TENANT_DATA_ENCRYPTION is still true \u2014 new writes will be re-encrypted until env is updated and replicas restarted.");
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (deactivateMaps && dryRun) {
|
|
999
|
+
console.log(`[dry-run] Would deactivate ${maps.length} EncryptionMap record(s).`);
|
|
944
1000
|
}
|
|
945
1001
|
if (!dryRun) {
|
|
946
1002
|
console.log(`
|