@open-mercato/core 0.7.1-develop.7183.1.db9678eeb8 → 0.7.1-develop.7186.1.6e080a5017

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.
@@ -8,7 +8,8 @@ import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
8
8
  import { emitIntegrationsEvent } from "../../../events.js";
9
9
  import { saveCredentialsSchema } from "../../../data/validators.js";
10
10
  import {
11
- isCredentialsEncryptionUnavailableError
11
+ isCredentialsEncryptionUnavailableError,
12
+ isCredentialsSealedWhileDisabledError
12
13
  } from "../../../lib/credentials-service.js";
13
14
  import { collectCredentialUrlValidationErrors } from "../../../lib/credentials-field-validation.js";
14
15
  import {
@@ -21,7 +22,15 @@ import {
21
22
  runIntegrationMutationGuards
22
23
  } from "../../guards.js";
23
24
  import { organizationScopeRequiredResponse, resolveActiveOrganizationId } from "@open-mercato/shared/lib/auth/organizationScope";
25
+ import { createLogger } from "@open-mercato/shared/lib/logger";
24
26
  const idParamsSchema = z.object({ id: z.string().min(1) });
27
+ const logger = createLogger("integrations").child({ component: "credentials-route" });
28
+ function reportSealedCredentials(integrationId, tenantId) {
29
+ logger.warn(
30
+ "Integration credentials are sealed under an encryption key that is no longer available (TENANT_DATA_ENCRYPTION was switched off after they were saved). The admin form will show them as unconfigured; re-save the credentials to store them in the clear.",
31
+ { integrationId, tenantId }
32
+ );
33
+ }
25
34
  const metadata = {
26
35
  GET: { requireAuth: true, requireFeatures: ["integrations.credentials.manage"] },
27
36
  PUT: { requireAuth: true, requireFeatures: ["integrations.credentials.manage"] }
@@ -64,10 +73,15 @@ async function GET(req, ctx) {
64
73
  values = await credentialsService.resolve(integration.id, scope);
65
74
  updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope);
66
75
  } catch (error) {
67
- if (isCredentialsEncryptionUnavailableError(error)) {
76
+ if (isCredentialsSealedWhileDisabledError(error)) {
77
+ reportSealedCredentials(integration.id, auth.tenantId);
78
+ values = null;
79
+ updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope);
80
+ } else if (isCredentialsEncryptionUnavailableError(error)) {
68
81
  return NextResponse.json({ error: "Integration credentials encryption is unavailable" }, { status: 503 });
82
+ } else {
83
+ throw error;
69
84
  }
70
- throw error;
71
85
  }
72
86
  const schema = credentialsService.getSchema(integration.id);
73
87
  const { credentials, secretFieldsConfigured } = maskSecretCredentials(schema, values ?? {});
@@ -161,7 +175,13 @@ async function PUT(req, ctx) {
161
175
  );
162
176
  }
163
177
  try {
164
- const existing = await credentialsService.resolve(integration.id, scope);
178
+ let existing = null;
179
+ try {
180
+ existing = await credentialsService.resolve(integration.id, scope);
181
+ } catch (error) {
182
+ if (!isCredentialsSealedWhileDisabledError(error)) throw error;
183
+ reportSealedCredentials(integration.id, auth.tenantId);
184
+ }
165
185
  const credentialsToSave = mergeMaskedSecretCredentials(
166
186
  schema,
167
187
  payloadData.credentials,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/integrations/api/%5Bid%5D/credentials/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getIntegration } from '@open-mercato/shared/modules/integrations/types'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { emitIntegrationsEvent } from '../../../events'\nimport { saveCredentialsSchema } from '../../../data/validators'\nimport {\n isCredentialsEncryptionUnavailableError,\n type CredentialsService,\n} from '../../../lib/credentials-service'\nimport { collectCredentialUrlValidationErrors } from '../../../lib/credentials-field-validation'\nimport {\n maskSecretCredentials,\n mergeMaskedSecretCredentials,\n} from '../../../lib/credentials-masking'\nimport {\n resolveUserFeatures,\n runIntegrationMutationGuardAfterSuccess,\n runIntegrationMutationGuards,\n} from '../../guards'\nimport { organizationScopeRequiredResponse, resolveActiveOrganizationId } from '@open-mercato/shared/lib/auth/organizationScope'\n\nconst idParamsSchema = z.object({ id: z.string().min(1) })\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },\n}\n\nexport const openApi = {\n tags: ['Integrations'],\n summary: 'Get or save integration credentials',\n}\n\nfunction resolveParams(ctx: { params?: Promise<{ id?: string }> | { id?: string } }): Promise<{ id?: string } | undefined> | { id?: string } | undefined {\n if (!ctx.params) return undefined\n if (typeof (ctx.params as Promise<unknown>).then === 'function') {\n return ctx.params as Promise<{ id?: string }>\n }\n return ctx.params as { id?: string }\n}\n\nexport async function GET(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const organizationId = resolveActiveOrganizationId(auth)\n if (!organizationId) {\n return organizationScopeRequiredResponse()\n }\n\n const rawParams = await resolveParams(ctx)\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const scope = { organizationId: organizationId, tenantId: auth.tenantId }\n\n let values: Record<string, unknown> | null\n let updatedAt: Date | null\n try {\n values = await credentialsService.resolve(integration.id, scope)\n updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)\n } catch (error) {\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n const schema = credentialsService.getSchema(integration.id)\n const { credentials, secretFieldsConfigured } = maskSecretCredentials(schema, values ?? {})\n\n return NextResponse.json({\n integrationId: integration.id,\n schema,\n credentials,\n secretFieldsConfigured,\n updatedAt: updatedAt?.toISOString() ?? null,\n })\n}\n\nexport async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const organizationId = resolveActiveOrganizationId(auth)\n if (!organizationId) {\n return organizationScopeRequiredResponse()\n }\n\n const rawParams = await resolveParams(ctx)\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const payload = await req.json().catch(() => null)\n const parsedBody = saveCredentialsSchema.safeParse(payload)\n if (!parsedBody.success) {\n return NextResponse.json({ error: 'Invalid credentials payload', details: parsedBody.error.flatten() }, { status: 422 })\n }\n\n const container = await createRequestContainer()\n const guardResult = await runIntegrationMutationGuards(\n container,\n {\n tenantId: auth.tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: parsedBody.data as Record<string, unknown>,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })\n }\n\n let payloadData = parsedBody.data\n if (guardResult.modifiedPayload) {\n const mergedPayload = { ...parsedBody.data, ...guardResult.modifiedPayload }\n const reparsed = saveCredentialsSchema.safeParse(mergedPayload)\n if (!reparsed.success) {\n return NextResponse.json({ error: 'Invalid credentials payload after guard transform', details: reparsed.error.flatten() }, { status: 422 })\n }\n payloadData = reparsed.data\n }\n\n const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const scope = { organizationId: organizationId, tenantId: auth.tenantId }\n const schema = credentialsService.getSchema(integration.id)\n\n try {\n const currentUpdatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)\n enforceCommandOptimisticLock({\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n current: currentUpdatedAt,\n request: req,\n })\n } catch (error) {\n if (isCrudHttpError(error)) {\n return NextResponse.json(error.body, { status: error.status })\n }\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n const credentialFieldErrors = collectCredentialUrlValidationErrors(\n schema,\n payloadData.credentials,\n )\n if (Object.keys(credentialFieldErrors).length > 0) {\n return NextResponse.json(\n { error: 'Invalid credentials payload', details: { fieldErrors: credentialFieldErrors } },\n { status: 422 },\n )\n }\n\n try {\n const existing = await credentialsService.resolve(integration.id, scope)\n const credentialsToSave = mergeMaskedSecretCredentials(\n schema,\n payloadData.credentials,\n existing ?? {},\n payloadData.unchangedSecretFields,\n )\n await credentialsService.save(integration.id, credentialsToSave, scope)\n } catch (error) {\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n await emitIntegrationsEvent('integrations.credentials.updated', {\n integrationId: integration.id,\n tenantId: auth.tenantId,\n organizationId,\n userId: auth.sub,\n })\n\n await runIntegrationMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId: auth.tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n\n return NextResponse.json({ ok: true })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getIntegration } from '@open-mercato/shared/modules/integrations/types'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { emitIntegrationsEvent } from '../../../events'\nimport { saveCredentialsSchema } from '../../../data/validators'\nimport {\n isCredentialsEncryptionUnavailableError,\n isCredentialsSealedWhileDisabledError,\n type CredentialsService,\n} from '../../../lib/credentials-service'\nimport { collectCredentialUrlValidationErrors } from '../../../lib/credentials-field-validation'\nimport {\n maskSecretCredentials,\n mergeMaskedSecretCredentials,\n} from '../../../lib/credentials-masking'\nimport {\n resolveUserFeatures,\n runIntegrationMutationGuardAfterSuccess,\n runIntegrationMutationGuards,\n} from '../../guards'\nimport { organizationScopeRequiredResponse, resolveActiveOrganizationId } from '@open-mercato/shared/lib/auth/organizationScope'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst idParamsSchema = z.object({ id: z.string().min(1) })\n\nconst logger = createLogger('integrations').child({ component: 'credentials-route' })\n\n/**\n * Credentials sealed before `TENANT_DATA_ENCRYPTION` was switched off cannot be opened by any key,\n * and nothing unseals them for the operator: `mercato entities decrypt-database` decrypts the\n * columns an encryption map covers, while this envelope sits *inside* the decrypted `credentials`\n * value. Re-entering them is the only remedy, so the admin surface has to stay usable \u2014 a 503 on\n * both the read and the save would leave the integration permanently unfixable from the UI.\n *\n * The form therefore loads as if nothing were configured. Adapters keep seeing the error, since\n * they read through the service directly.\n */\nfunction reportSealedCredentials(integrationId: string, tenantId: string): void {\n logger.warn(\n 'Integration credentials are sealed under an encryption key that is no longer available '\n + '(TENANT_DATA_ENCRYPTION was switched off after they were saved). The admin form will show '\n + 'them as unconfigured; re-save the credentials to store them in the clear.',\n { integrationId, tenantId },\n )\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },\n}\n\nexport const openApi = {\n tags: ['Integrations'],\n summary: 'Get or save integration credentials',\n}\n\nfunction resolveParams(ctx: { params?: Promise<{ id?: string }> | { id?: string } }): Promise<{ id?: string } | undefined> | { id?: string } | undefined {\n if (!ctx.params) return undefined\n if (typeof (ctx.params as Promise<unknown>).then === 'function') {\n return ctx.params as Promise<{ id?: string }>\n }\n return ctx.params as { id?: string }\n}\n\nexport async function GET(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const organizationId = resolveActiveOrganizationId(auth)\n if (!organizationId) {\n return organizationScopeRequiredResponse()\n }\n\n const rawParams = await resolveParams(ctx)\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const scope = { organizationId: organizationId, tenantId: auth.tenantId }\n\n let values: Record<string, unknown> | null\n let updatedAt: Date | null\n try {\n values = await credentialsService.resolve(integration.id, scope)\n updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)\n } catch (error) {\n if (isCredentialsSealedWhileDisabledError(error)) {\n reportSealedCredentials(integration.id, auth.tenantId)\n values = null\n updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)\n } else if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n } else {\n throw error\n }\n }\n\n const schema = credentialsService.getSchema(integration.id)\n const { credentials, secretFieldsConfigured } = maskSecretCredentials(schema, values ?? {})\n\n return NextResponse.json({\n integrationId: integration.id,\n schema,\n credentials,\n secretFieldsConfigured,\n updatedAt: updatedAt?.toISOString() ?? null,\n })\n}\n\nexport async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const organizationId = resolveActiveOrganizationId(auth)\n if (!organizationId) {\n return organizationScopeRequiredResponse()\n }\n\n const rawParams = await resolveParams(ctx)\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const payload = await req.json().catch(() => null)\n const parsedBody = saveCredentialsSchema.safeParse(payload)\n if (!parsedBody.success) {\n return NextResponse.json({ error: 'Invalid credentials payload', details: parsedBody.error.flatten() }, { status: 422 })\n }\n\n const container = await createRequestContainer()\n const guardResult = await runIntegrationMutationGuards(\n container,\n {\n tenantId: auth.tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: parsedBody.data as Record<string, unknown>,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })\n }\n\n let payloadData = parsedBody.data\n if (guardResult.modifiedPayload) {\n const mergedPayload = { ...parsedBody.data, ...guardResult.modifiedPayload }\n const reparsed = saveCredentialsSchema.safeParse(mergedPayload)\n if (!reparsed.success) {\n return NextResponse.json({ error: 'Invalid credentials payload after guard transform', details: reparsed.error.flatten() }, { status: 422 })\n }\n payloadData = reparsed.data\n }\n\n const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const scope = { organizationId: organizationId, tenantId: auth.tenantId }\n const schema = credentialsService.getSchema(integration.id)\n\n try {\n const currentUpdatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)\n enforceCommandOptimisticLock({\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n current: currentUpdatedAt,\n request: req,\n })\n } catch (error) {\n if (isCrudHttpError(error)) {\n return NextResponse.json(error.body, { status: error.status })\n }\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n const credentialFieldErrors = collectCredentialUrlValidationErrors(\n schema,\n payloadData.credentials,\n )\n if (Object.keys(credentialFieldErrors).length > 0) {\n return NextResponse.json(\n { error: 'Invalid credentials payload', details: { fieldErrors: credentialFieldErrors } },\n { status: 422 },\n )\n }\n\n try {\n let existing: Record<string, unknown> | null = null\n try {\n existing = await credentialsService.resolve(integration.id, scope)\n } catch (error) {\n // Nothing to merge against, but the save itself must go through: this is the state the\n // re-entry is meant to escape from.\n if (!isCredentialsSealedWhileDisabledError(error)) throw error\n reportSealedCredentials(integration.id, auth.tenantId)\n }\n const credentialsToSave = mergeMaskedSecretCredentials(\n schema,\n payloadData.credentials,\n existing ?? {},\n payloadData.unchangedSecretFields,\n )\n await credentialsService.save(integration.id, credentialsToSave, scope)\n } catch (error) {\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n await emitIntegrationsEvent('integrations.credentials.updated', {\n integrationId: integration.id,\n tenantId: auth.tenantId,\n organizationId,\n userId: auth.sub,\n })\n\n await runIntegrationMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId: auth.tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n\n return NextResponse.json({ ok: true })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,6 +1,6 @@
1
1
  import { decryptWithAesGcm, encryptWithAesGcm } from "@open-mercato/shared/lib/encryption/aes";
2
2
  import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
- import { createKmsService } from "@open-mercato/shared/lib/encryption/kms";
3
+ import { createKmsService, resolveEncryptionMode } from "@open-mercato/shared/lib/encryption/kms";
4
4
  import { parseDecryptedFieldValue } from "@open-mercato/shared/lib/encryption/tenantDataEncryptionService";
5
5
  import {
6
6
  getBundle,
@@ -10,18 +10,26 @@ import {
10
10
  import { EncryptionMap } from "../../entities/data/entities.js";
11
11
  import { IntegrationCredentials } from "../data/entities.js";
12
12
  const ENCRYPTED_CREDENTIALS_BLOB_KEY = "__om_encrypted_credentials_blob_v1";
13
+ const CREDENTIALS_ENCRYPTION_REMEDY = {
14
+ "no-dek": "no tenant DEK is available. Configure Vault (VAULT_ADDR/VAULT_TOKEN) or set TENANT_DATA_ENCRYPTION_FALLBACK_KEY in the environment.",
15
+ "sealed-while-disabled": "they were sealed while TENANT_DATA_ENCRYPTION was on and it is now off, so no key can open them. Re-enable TENANT_DATA_ENCRYPTION to read them again, or re-enter the credentials \u2014 saving them while the toggle is off stores them in the clear. Note that `mercato entities decrypt-database` does not reach this blob: it decrypts the columns an encryption map covers, and this envelope sits inside the decrypted value."
16
+ };
13
17
  class CredentialsEncryptionUnavailableError extends Error {
14
- constructor(tenantId) {
18
+ constructor(tenantId, reason = "no-dek") {
15
19
  super(
16
- `Cannot encrypt or decrypt integration credentials for tenant ${tenantId}: no tenant DEK is available. Configure Vault (VAULT_ADDR/VAULT_TOKEN) or set TENANT_DATA_ENCRYPTION_FALLBACK_KEY in the environment.`
20
+ `Cannot encrypt or decrypt integration credentials for tenant ${tenantId}: ` + CREDENTIALS_ENCRYPTION_REMEDY[reason]
17
21
  );
18
22
  this.code = "CREDENTIALS_ENCRYPTION_UNAVAILABLE";
19
23
  this.name = "CredentialsEncryptionUnavailableError";
24
+ this.reason = reason;
20
25
  }
21
26
  }
22
27
  function isCredentialsEncryptionUnavailableError(error) {
23
28
  return error instanceof CredentialsEncryptionUnavailableError;
24
29
  }
30
+ function isCredentialsSealedWhileDisabledError(error) {
31
+ return isCredentialsEncryptionUnavailableError(error) && error.reason === "sealed-while-disabled";
32
+ }
25
33
  function isRecordValue(value) {
26
34
  return !!value && typeof value === "object" && !Array.isArray(value);
27
35
  }
@@ -78,6 +86,7 @@ function createCredentialsService(em) {
78
86
  }
79
87
  async function resolveCredentialsDek(scope) {
80
88
  const kms = createKmsService();
89
+ if (resolveEncryptionMode(kms) === "disabled") return null;
81
90
  const existing = await kms.getTenantDek(scope.tenantId);
82
91
  if (existing?.key) return existing.key;
83
92
  const created = await kms.createTenantDek(scope.tenantId);
@@ -86,6 +95,7 @@ function createCredentialsService(em) {
86
95
  }
87
96
  async function encryptCredentialsBlob(credentials, scope) {
88
97
  const dek = await resolveCredentialsDek(scope);
98
+ if (!dek) return credentials;
89
99
  const payload = encryptWithAesGcm(JSON.stringify(credentials), dek);
90
100
  return { [ENCRYPTED_CREDENTIALS_BLOB_KEY]: payload.value };
91
101
  }
@@ -94,6 +104,7 @@ function createCredentialsService(em) {
94
104
  const encrypted = credentials[ENCRYPTED_CREDENTIALS_BLOB_KEY];
95
105
  if (typeof encrypted !== "string" || !encrypted) return credentials;
96
106
  const dek = await resolveCredentialsDek(scope);
107
+ if (!dek) throw new CredentialsEncryptionUnavailableError(scope.tenantId, "sealed-while-disabled");
97
108
  const decryptedRaw = decryptWithAesGcm(encrypted, dek);
98
109
  if (!decryptedRaw) return {};
99
110
  try {
@@ -208,6 +219,7 @@ export {
208
219
  CredentialsEncryptionUnavailableError,
209
220
  buildCredentialsFilter,
210
221
  createCredentialsService,
211
- isCredentialsEncryptionUnavailableError
222
+ isCredentialsEncryptionUnavailableError,
223
+ isCredentialsSealedWhileDisabledError
212
224
  };
213
225
  //# sourceMappingURL=credentials-service.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/integrations/lib/credentials-service.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { decryptWithAesGcm, encryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createKmsService } from '@open-mercato/shared/lib/encryption/kms'\nimport { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport {\n getBundle,\n getIntegration,\n resolveIntegrationCredentialsSchema,\n type IntegrationScope,\n} from '@open-mercato/shared/modules/integrations/types'\nimport { EncryptionMap } from '../../entities/data/entities'\nimport { IntegrationCredentials } from '../data/entities'\n\nconst ENCRYPTED_CREDENTIALS_BLOB_KEY = '__om_encrypted_credentials_blob_v1'\n\n/**\n * Raised when integration credentials cannot be encrypted or decrypted because\n * no tenant DEK is available \u2014 typically a production deployment with neither\n * Vault nor `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` (or equivalent env vars)\n * configured. The credentials path deliberately fails closed instead of using\n * a hardcoded fallback secret; see security tracker finding #7.\n */\nexport class CredentialsEncryptionUnavailableError extends Error {\n readonly code = 'CREDENTIALS_ENCRYPTION_UNAVAILABLE'\n constructor(tenantId: string) {\n super(\n `Cannot encrypt or decrypt integration credentials for tenant ${tenantId}: ` +\n `no tenant DEK is available. Configure Vault (VAULT_ADDR/VAULT_TOKEN) or ` +\n `set TENANT_DATA_ENCRYPTION_FALLBACK_KEY in the environment.`,\n )\n this.name = 'CredentialsEncryptionUnavailableError'\n }\n}\n\nexport function isCredentialsEncryptionUnavailableError(error: unknown): error is CredentialsEncryptionUnavailableError {\n return error instanceof CredentialsEncryptionUnavailableError\n}\n\nfunction isRecordValue(value: unknown): value is Record<string, unknown> {\n return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction normalizeCredentialsRecord(value: unknown): Record<string, unknown> {\n if (isRecordValue(value)) return value\n if (typeof value !== 'string') return {}\n\n const parsed = parseDecryptedFieldValue(value)\n return isRecordValue(parsed) ? parsed : {}\n}\n\n/**\n * Build the where-filter for credential lookups.\n *\n * Per-user scoping (added 2026-05-26): when `scope.userId` is set, the filter\n * matches the row owned by that user \u2014 different users on the same tenant get\n * their OWN row for the same provider. When `scope.userId` is `undefined` /\n * `null`, the filter matches tenant-wide credentials (existing behaviour,\n * e.g. shared Stripe/Akeneo API keys).\n *\n * The partial unique index `integration_credentials_user_lookup_idx` enforces\n * uniqueness across `(integration_id, organization_id, tenant_id, user_id)`\n * when `user_id IS NOT NULL`.\n */\nexport function buildCredentialsFilter(integrationId: string, scope: IntegrationScope) {\n const base = {\n integrationId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n } as Record<string, unknown>\n if (scope.userId) {\n base.userId = scope.userId\n } else {\n base.userId = null\n }\n return base\n}\n\nexport function createCredentialsService(em: EntityManager) {\n const credentialsEncryptionSpec = [{ field: 'credentials' }]\n\n async function ensureCredentialsEncryptionMap(scope: IntegrationScope): Promise<void> {\n const existing = await findOneWithDecryption(\n em,\n EncryptionMap,\n {\n entityId: 'integrations:integration_credentials',\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n\n if (!existing) {\n const created = em.create(EncryptionMap, {\n entityId: 'integrations:integration_credentials',\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n fieldsJson: credentialsEncryptionSpec,\n isActive: true,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n em.persist(created)\n return\n }\n\n existing.fieldsJson = credentialsEncryptionSpec\n existing.isActive = true\n }\n\n async function resolveCredentialsDek(scope: IntegrationScope): Promise<string> {\n const kms = createKmsService()\n const existing = await kms.getTenantDek(scope.tenantId)\n if (existing?.key) return existing.key\n\n const created = await kms.createTenantDek(scope.tenantId)\n if (created?.key) return created.key\n\n throw new CredentialsEncryptionUnavailableError(scope.tenantId)\n }\n\n async function encryptCredentialsBlob(\n credentials: Record<string, unknown>,\n scope: IntegrationScope,\n ): Promise<Record<string, unknown>> {\n const dek = await resolveCredentialsDek(scope)\n const payload = encryptWithAesGcm(JSON.stringify(credentials), dek)\n return { [ENCRYPTED_CREDENTIALS_BLOB_KEY]: payload.value }\n }\n\n async function decryptCredentialsBlob(\n credentialsInput: unknown,\n scope: IntegrationScope,\n ): Promise<Record<string, unknown>> {\n const credentials = normalizeCredentialsRecord(credentialsInput)\n const encrypted = credentials[ENCRYPTED_CREDENTIALS_BLOB_KEY]\n if (typeof encrypted !== 'string' || !encrypted) return credentials\n\n const dek = await resolveCredentialsDek(scope)\n const decryptedRaw = decryptWithAesGcm(encrypted, dek)\n if (!decryptedRaw) return {}\n\n try {\n const parsed = JSON.parse(decryptedRaw) as unknown\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {}\n } catch {\n return {}\n }\n }\n\n return {\n async getRaw(integrationId: string, scope: IntegrationScope): Promise<Record<string, unknown> | null> {\n let row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, scope),\n undefined,\n scope,\n )\n // Spec 2026-05-21 (email-integration-foundation) \"Hub credentials store\":\n // per-user secrets resolve as `WHERE user_id = currentUser.id OR user_id IS NULL`.\n // A user-scoped read of a TENANT-WIDE integration (sync_excel, Stripe, Akeneo,\n // S3, the channel OAuth *client* config) MUST still find the shared\n // `user_id = NULL` row \u2014 the per-user row takes precedence, and we only fall\n // back to the tenant-wide row when the user has none of their own. Writes stay\n // strict (`save` uses the unmodified filter) so a per-user save never clobbers\n // the shared credential.\n if (!row && scope.userId) {\n row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, { ...scope, userId: null }),\n undefined,\n scope,\n )\n }\n if (!row) return null\n return decryptCredentialsBlob(row.credentials, scope)\n },\n\n async getRowUpdatedAt(integrationId: string, scope: IntegrationScope): Promise<Date | null> {\n let row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, scope),\n undefined,\n scope,\n )\n if (!row && scope.userId) {\n row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, { ...scope, userId: null }),\n undefined,\n scope,\n )\n }\n return row?.updatedAt ?? null\n },\n\n /**\n * Resolve the persisted `updated_at` version for the credentials a caller\n * would read via {@link resolve} (direct row first, then the bundle\n * fallthrough). Returns `null` when no credentials row exists yet \u2014 the\n * optimistic-lock guard treats a missing current version as \"no conflict\".\n */\n async resolveUpdatedAt(integrationId: string, scope: IntegrationScope): Promise<Date | null> {\n const direct = await this.getRowUpdatedAt(integrationId, scope)\n if (direct) return direct\n\n const definition = getIntegration(integrationId)\n if (!definition?.bundleId) return null\n return this.getRowUpdatedAt(definition.bundleId, scope)\n },\n\n async resolve(integrationId: string, scope: IntegrationScope): Promise<Record<string, unknown> | null> {\n const direct = await this.getRaw(integrationId, scope)\n if (direct) return direct\n\n const definition = getIntegration(integrationId)\n if (!definition?.bundleId) return null\n return this.getRaw(definition.bundleId, scope)\n },\n\n async save(integrationId: string, credentials: Record<string, unknown>, scope: IntegrationScope): Promise<void> {\n const encryptedCredentials = await encryptCredentialsBlob(credentials, scope)\n await ensureCredentialsEncryptionMap(scope)\n\n const row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, scope),\n undefined,\n scope,\n )\n\n if (row) {\n row.credentials = encryptedCredentials\n await em.flush()\n return\n }\n\n const created = em.create(IntegrationCredentials, {\n integrationId,\n credentials: encryptedCredentials,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n ...(scope.userId ? { userId: scope.userId } : {}),\n })\n await em.persist(created).flush()\n },\n\n async saveField(\n integrationId: string,\n fieldKey: string,\n value: unknown,\n scope: IntegrationScope,\n ): Promise<Record<string, unknown>> {\n const current = (await this.getRaw(integrationId, scope)) ?? {}\n const updated = { ...current, [fieldKey]: value }\n await this.save(integrationId, updated, scope)\n return updated\n },\n\n getSchema(integrationId: string) {\n const definition = getIntegration(integrationId)\n if (!definition) return undefined\n\n if (definition.bundleId) {\n const bundle = getBundle(definition.bundleId)\n return bundle?.credentials ?? resolveIntegrationCredentialsSchema(integrationId)\n }\n\n return definition.credentials ?? resolveIntegrationCredentialsSchema(integrationId)\n },\n }\n}\n\nexport type CredentialsService = ReturnType<typeof createCredentialsService>\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { decryptWithAesGcm, encryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createKmsService, resolveEncryptionMode } from '@open-mercato/shared/lib/encryption/kms'\nimport { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport {\n getBundle,\n getIntegration,\n resolveIntegrationCredentialsSchema,\n type IntegrationScope,\n} from '@open-mercato/shared/modules/integrations/types'\nimport { EncryptionMap } from '../../entities/data/entities'\nimport { IntegrationCredentials } from '../data/entities'\n\nconst ENCRYPTED_CREDENTIALS_BLOB_KEY = '__om_encrypted_credentials_blob_v1'\n\n/**\n * Raised when integration credentials cannot be encrypted or decrypted because\n * no tenant DEK is available \u2014 typically a production deployment with neither\n * Vault nor `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` (or equivalent env vars)\n * configured. The credentials path deliberately fails closed instead of using\n * a hardcoded fallback secret; see security tracker finding #7.\n */\nexport type CredentialsEncryptionUnavailableReason = 'no-dek' | 'sealed-while-disabled'\n\nconst CREDENTIALS_ENCRYPTION_REMEDY: Record<CredentialsEncryptionUnavailableReason, string> = {\n 'no-dek':\n 'no tenant DEK is available. Configure Vault (VAULT_ADDR/VAULT_TOKEN) or ' +\n 'set TENANT_DATA_ENCRYPTION_FALLBACK_KEY in the environment.',\n 'sealed-while-disabled':\n 'they were sealed while TENANT_DATA_ENCRYPTION was on and it is now off, so no key can open ' +\n 'them. Re-enable TENANT_DATA_ENCRYPTION to read them again, or re-enter the credentials \u2014 ' +\n 'saving them while the toggle is off stores them in the clear. Note that ' +\n '`mercato entities decrypt-database` does not reach this blob: it decrypts the columns an ' +\n 'encryption map covers, and this envelope sits inside the decrypted value.',\n}\n\nexport class CredentialsEncryptionUnavailableError extends Error {\n readonly code = 'CREDENTIALS_ENCRYPTION_UNAVAILABLE'\n readonly reason: CredentialsEncryptionUnavailableReason\n constructor(tenantId: string, reason: CredentialsEncryptionUnavailableReason = 'no-dek') {\n super(\n `Cannot encrypt or decrypt integration credentials for tenant ${tenantId}: ` +\n CREDENTIALS_ENCRYPTION_REMEDY[reason],\n )\n this.name = 'CredentialsEncryptionUnavailableError'\n this.reason = reason\n }\n}\n\nexport function isCredentialsEncryptionUnavailableError(error: unknown): error is CredentialsEncryptionUnavailableError {\n return error instanceof CredentialsEncryptionUnavailableError\n}\n\n/**\n * The one unavailable-reason an operator can act on without restoring a key: the blob predates\n * `TENANT_DATA_ENCRYPTION=no` and no key exists to open it, so re-entering the credentials is the\n * only way forward. The admin credentials route degrades to an empty form on this so that the\n * re-entry is possible at all; `no-dek` (encryption on, KMS unreachable) still fails closed.\n */\nexport function isCredentialsSealedWhileDisabledError(error: unknown): boolean {\n return isCredentialsEncryptionUnavailableError(error) && error.reason === 'sealed-while-disabled'\n}\n\nfunction isRecordValue(value: unknown): value is Record<string, unknown> {\n return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction normalizeCredentialsRecord(value: unknown): Record<string, unknown> {\n if (isRecordValue(value)) return value\n if (typeof value !== 'string') return {}\n\n const parsed = parseDecryptedFieldValue(value)\n return isRecordValue(parsed) ? parsed : {}\n}\n\n/**\n * Build the where-filter for credential lookups.\n *\n * Per-user scoping (added 2026-05-26): when `scope.userId` is set, the filter\n * matches the row owned by that user \u2014 different users on the same tenant get\n * their OWN row for the same provider. When `scope.userId` is `undefined` /\n * `null`, the filter matches tenant-wide credentials (existing behaviour,\n * e.g. shared Stripe/Akeneo API keys).\n *\n * The partial unique index `integration_credentials_user_lookup_idx` enforces\n * uniqueness across `(integration_id, organization_id, tenant_id, user_id)`\n * when `user_id IS NOT NULL`.\n */\nexport function buildCredentialsFilter(integrationId: string, scope: IntegrationScope) {\n const base = {\n integrationId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n } as Record<string, unknown>\n if (scope.userId) {\n base.userId = scope.userId\n } else {\n base.userId = null\n }\n return base\n}\n\nexport function createCredentialsService(em: EntityManager) {\n const credentialsEncryptionSpec = [{ field: 'credentials' }]\n\n async function ensureCredentialsEncryptionMap(scope: IntegrationScope): Promise<void> {\n const existing = await findOneWithDecryption(\n em,\n EncryptionMap,\n {\n entityId: 'integrations:integration_credentials',\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n\n if (!existing) {\n const created = em.create(EncryptionMap, {\n entityId: 'integrations:integration_credentials',\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n fieldsJson: credentialsEncryptionSpec,\n isActive: true,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n em.persist(created)\n return\n }\n\n existing.fieldsJson = credentialsEncryptionSpec\n existing.isActive = true\n }\n\n /**\n * Resolve the DEK this tenant's credentials blob is sealed with, or `null` when the operator\n * has switched tenant data encryption off.\n *\n * The `null` is the whole point of going through {@link resolveEncryptionMode} rather than\n * asking the KMS directly. Under `TENANT_DATA_ENCRYPTION=no` the KMS is a noop and hands back\n * nothing, which is indistinguishable \u2014 to `getTenantDek` alone \u2014 from Vault being down. Those\n * two need opposite answers: an operator who turned encryption off expects plaintext, whereas a\n * Vault outage must not silently downgrade a secret that is supposed to be sealed. So only\n * `unavailable` throws.\n */\n async function resolveCredentialsDek(scope: IntegrationScope): Promise<string | null> {\n const kms = createKmsService()\n if (resolveEncryptionMode(kms) === 'disabled') return null\n\n const existing = await kms.getTenantDek(scope.tenantId)\n if (existing?.key) return existing.key\n\n const created = await kms.createTenantDek(scope.tenantId)\n if (created?.key) return created.key\n\n throw new CredentialsEncryptionUnavailableError(scope.tenantId)\n }\n\n async function encryptCredentialsBlob(\n credentials: Record<string, unknown>,\n scope: IntegrationScope,\n ): Promise<Record<string, unknown>> {\n const dek = await resolveCredentialsDek(scope)\n if (!dek) return credentials\n const payload = encryptWithAesGcm(JSON.stringify(credentials), dek)\n return { [ENCRYPTED_CREDENTIALS_BLOB_KEY]: payload.value }\n }\n\n async function decryptCredentialsBlob(\n credentialsInput: unknown,\n scope: IntegrationScope,\n ): Promise<Record<string, unknown>> {\n const credentials = normalizeCredentialsRecord(credentialsInput)\n const encrypted = credentials[ENCRYPTED_CREDENTIALS_BLOB_KEY]\n if (typeof encrypted !== 'string' || !encrypted) return credentials\n\n // A sealed blob written before encryption was switched off. There is no key to open it with,\n // and returning the envelope as if it were the credentials would hand an adapter a garbage\n // secret, so this stays an error even in `disabled` mode rather than a silent empty credential\n // set. The remedy is re-entering the credentials (see the reason's message); the admin route\n // catches this specific reason so the form can load empty and accept them.\n const dek = await resolveCredentialsDek(scope)\n if (!dek) throw new CredentialsEncryptionUnavailableError(scope.tenantId, 'sealed-while-disabled')\n\n const decryptedRaw = decryptWithAesGcm(encrypted, dek)\n if (!decryptedRaw) return {}\n\n try {\n const parsed = JSON.parse(decryptedRaw) as unknown\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {}\n } catch {\n return {}\n }\n }\n\n return {\n async getRaw(integrationId: string, scope: IntegrationScope): Promise<Record<string, unknown> | null> {\n let row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, scope),\n undefined,\n scope,\n )\n // Spec 2026-05-21 (email-integration-foundation) \"Hub credentials store\":\n // per-user secrets resolve as `WHERE user_id = currentUser.id OR user_id IS NULL`.\n // A user-scoped read of a TENANT-WIDE integration (sync_excel, Stripe, Akeneo,\n // S3, the channel OAuth *client* config) MUST still find the shared\n // `user_id = NULL` row \u2014 the per-user row takes precedence, and we only fall\n // back to the tenant-wide row when the user has none of their own. Writes stay\n // strict (`save` uses the unmodified filter) so a per-user save never clobbers\n // the shared credential.\n if (!row && scope.userId) {\n row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, { ...scope, userId: null }),\n undefined,\n scope,\n )\n }\n if (!row) return null\n return decryptCredentialsBlob(row.credentials, scope)\n },\n\n async getRowUpdatedAt(integrationId: string, scope: IntegrationScope): Promise<Date | null> {\n let row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, scope),\n undefined,\n scope,\n )\n if (!row && scope.userId) {\n row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, { ...scope, userId: null }),\n undefined,\n scope,\n )\n }\n return row?.updatedAt ?? null\n },\n\n /**\n * Resolve the persisted `updated_at` version for the credentials a caller\n * would read via {@link resolve} (direct row first, then the bundle\n * fallthrough). Returns `null` when no credentials row exists yet \u2014 the\n * optimistic-lock guard treats a missing current version as \"no conflict\".\n */\n async resolveUpdatedAt(integrationId: string, scope: IntegrationScope): Promise<Date | null> {\n const direct = await this.getRowUpdatedAt(integrationId, scope)\n if (direct) return direct\n\n const definition = getIntegration(integrationId)\n if (!definition?.bundleId) return null\n return this.getRowUpdatedAt(definition.bundleId, scope)\n },\n\n async resolve(integrationId: string, scope: IntegrationScope): Promise<Record<string, unknown> | null> {\n const direct = await this.getRaw(integrationId, scope)\n if (direct) return direct\n\n const definition = getIntegration(integrationId)\n if (!definition?.bundleId) return null\n return this.getRaw(definition.bundleId, scope)\n },\n\n async save(integrationId: string, credentials: Record<string, unknown>, scope: IntegrationScope): Promise<void> {\n const encryptedCredentials = await encryptCredentialsBlob(credentials, scope)\n await ensureCredentialsEncryptionMap(scope)\n\n const row = await findOneWithDecryption(\n em,\n IntegrationCredentials,\n buildCredentialsFilter(integrationId, scope),\n undefined,\n scope,\n )\n\n if (row) {\n row.credentials = encryptedCredentials\n await em.flush()\n return\n }\n\n const created = em.create(IntegrationCredentials, {\n integrationId,\n credentials: encryptedCredentials,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n ...(scope.userId ? { userId: scope.userId } : {}),\n })\n await em.persist(created).flush()\n },\n\n async saveField(\n integrationId: string,\n fieldKey: string,\n value: unknown,\n scope: IntegrationScope,\n ): Promise<Record<string, unknown>> {\n const current = (await this.getRaw(integrationId, scope)) ?? {}\n const updated = { ...current, [fieldKey]: value }\n await this.save(integrationId, updated, scope)\n return updated\n },\n\n getSchema(integrationId: string) {\n const definition = getIntegration(integrationId)\n if (!definition) return undefined\n\n if (definition.bundleId) {\n const bundle = getBundle(definition.bundleId)\n return bundle?.credentials ?? resolveIntegrationCredentialsSchema(integrationId)\n }\n\n return definition.credentials ?? resolveIntegrationCredentialsSchema(integrationId)\n },\n }\n}\n\nexport type CredentialsService = ReturnType<typeof createCredentialsService>\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,15 @@
1
1
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
2
+ import { createLogger } from "@open-mercato/shared/lib/logger";
3
+ import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
4
+ import { groupableCode } from "@open-mercato/shared/lib/telemetry/error-code";
2
5
  import { IntegrationLog } from "../data/entities.js";
6
+ const logger = createLogger("integrations");
7
+ class IntegrationLogError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "IntegrationLogError";
11
+ }
12
+ }
3
13
  function startOfUtcDay(d) {
4
14
  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
5
15
  }
@@ -13,6 +23,27 @@ function buildUtcDayKeys(windowDays) {
13
23
  }
14
24
  return keys;
15
25
  }
26
+ function reportErrorLog(input, scope) {
27
+ try {
28
+ getTelemetryRuntime()?.reportError(new IntegrationLogError(input.message), {
29
+ module: "integrations",
30
+ code: groupableCode(input.code, "integrations.log_error"),
31
+ attributes: {
32
+ integrationId: input.integrationId,
33
+ runId: input.runId ?? void 0,
34
+ scopeEntityType: input.scopeEntityType ?? void 0,
35
+ scopeEntityId: input.scopeEntityId ?? void 0,
36
+ organizationId: scope.organizationId,
37
+ tenantId: scope.tenantId
38
+ }
39
+ });
40
+ } catch (telemetryError) {
41
+ logger.warn("Failed to report an integration error log to telemetry", {
42
+ integrationId: input.integrationId,
43
+ err: telemetryError
44
+ });
45
+ }
46
+ }
16
47
  function createIntegrationLogService(em) {
17
48
  return {
18
49
  async write(input, scope) {
@@ -29,6 +60,7 @@ function createIntegrationLogService(em) {
29
60
  tenantId: scope.tenantId
30
61
  });
31
62
  await em.persist(row).flush();
63
+ if (input.level === "error") reportErrorLog(input, scope);
32
64
  return row;
33
65
  },
34
66
  scoped(integrationId, scope) {
@@ -140,6 +172,7 @@ function createIntegrationLogService(em) {
140
172
  };
141
173
  }
142
174
  export {
175
+ IntegrationLogError,
143
176
  createIntegrationLogService
144
177
  };
145
178
  //# sourceMappingURL=log-service.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/integrations/lib/log-service.ts"],
4
- "sourcesContent": ["import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport type { ListIntegrationLogsQuery } from '../data/validators'\nimport { IntegrationLog } from '../data/entities'\n\nexport type IntegrationLogAnalytics = {\n lastActivityAt: string | null\n totalCount: number\n errorCount: number\n errorRate: number\n dailyCounts: number[]\n}\n\nfunction startOfUtcDay(d: Date): Date {\n return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))\n}\n\nfunction buildUtcDayKeys(windowDays: number): string[] {\n const end = startOfUtcDay(new Date())\n const keys: string[] = []\n for (let offset = windowDays - 1; offset >= 0; offset -= 1) {\n const t = new Date(end)\n t.setUTCDate(t.getUTCDate() - offset)\n keys.push(t.toISOString().slice(0, 10))\n }\n return keys\n}\n\ntype LogInput = {\n integrationId: string\n runId?: string | null\n scopeEntityType?: string | null\n scopeEntityId?: string | null\n level: 'info' | 'warn' | 'error'\n message: string\n code?: string | null\n payload?: Record<string, unknown> | null\n}\n\nexport function createIntegrationLogService(em: EntityManager) {\n return {\n async write(input: LogInput, scope: IntegrationScope): Promise<IntegrationLog> {\n const row = em.create(IntegrationLog, {\n integrationId: input.integrationId,\n runId: input.runId,\n scopeEntityType: input.scopeEntityType,\n scopeEntityId: input.scopeEntityId,\n level: input.level,\n message: input.message,\n code: input.code,\n payload: input.payload,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n })\n await em.persist(row).flush()\n return row\n },\n\n scoped(integrationId: string, scope: IntegrationScope) {\n return {\n info: (message: string, payload?: Record<string, unknown>) => this.write({ integrationId, level: 'info', message, payload }, scope),\n warn: (message: string, payload?: Record<string, unknown>) => this.write({ integrationId, level: 'warn', message, payload }, scope),\n error: (message: string, payload?: Record<string, unknown>) => this.write({ integrationId, level: 'error', message, payload }, scope),\n }\n },\n\n async query(query: ListIntegrationLogsQuery, scope: IntegrationScope): Promise<{ items: IntegrationLog[]; total: number }> {\n const where: FilterQuery<IntegrationLog> = {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n }\n\n if (query.integrationId) where.integrationId = query.integrationId\n if (query.level) where.level = query.level\n if (query.runId) where.runId = query.runId\n if (query.entityType) where.scopeEntityType = query.entityType\n if (query.entityId) where.scopeEntityId = query.entityId\n\n const items = await findWithDecryption(\n em,\n IntegrationLog,\n where,\n {\n orderBy: { createdAt: 'DESC' },\n limit: query.pageSize,\n offset: (query.page - 1) * query.pageSize,\n },\n scope,\n )\n const total = await em.count(IntegrationLog, where)\n return { items, total }\n },\n\n async pruneOlderThan(days: number, scope: IntegrationScope): Promise<number> {\n const threshold = new Date(Date.now() - days * 24 * 60 * 60 * 1000)\n const deletedCount = await em.nativeDelete(IntegrationLog, {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n createdAt: { $lt: threshold },\n })\n return deletedCount\n },\n\n async aggregateAnalytics(\n integrationIds: string[],\n scope: IntegrationScope,\n windowDays = 30,\n ): Promise<Map<string, IntegrationLogAnalytics>> {\n const dayKeys = buildUtcDayKeys(windowDays)\n const windowStartUtc = `${dayKeys[0]}T00:00:00.000Z`\n\n const empty = (): IntegrationLogAnalytics => ({\n lastActivityAt: null,\n totalCount: 0,\n errorCount: 0,\n errorRate: 0,\n dailyCounts: dayKeys.map(() => 0),\n })\n\n const result = new Map<string, IntegrationLogAnalytics>()\n for (const id of integrationIds) {\n result.set(id, empty())\n }\n if (integrationIds.length === 0) {\n return result\n }\n\n const conn = em.getConnection()\n const inList = integrationIds.map(() => '?').join(', ')\n\n type AggRow = {\n integration_id: string\n last_activity: Date | string | null\n total_count: string | number\n error_count: string | number | null\n }\n\n const aggRows = await conn.execute<AggRow[]>(\n `select integration_id,\n max(created_at) as last_activity,\n count(*)::int as total_count,\n coalesce(sum(case when level = 'error' then 1 else 0 end), 0)::int as error_count\n from integration_logs\n where organization_id = ? and tenant_id = ?\n and integration_id in (${inList})\n and created_at >= ?\n group by integration_id`,\n [scope.organizationId, scope.tenantId, ...integrationIds, windowStartUtc],\n )\n\n for (const row of aggRows) {\n const entry = result.get(row.integration_id)\n if (!entry) continue\n const total = Number(row.total_count)\n const errors = Number(row.error_count ?? 0)\n entry.totalCount = total\n entry.errorCount = errors\n entry.errorRate = total > 0 ? errors / total : 0\n if (row.last_activity) {\n const d = row.last_activity instanceof Date ? row.last_activity : new Date(row.last_activity)\n entry.lastActivityAt = d.toISOString()\n }\n }\n\n type DailyRow = { integration_id: string; day: string | Date; cnt: string | number }\n\n const dailyRows = await conn.execute<DailyRow[]>(\n `select integration_id,\n (created_at at time zone 'UTC')::date::text as day,\n count(*)::int as cnt\n from integration_logs\n where organization_id = ? and tenant_id = ?\n and integration_id in (${inList})\n and created_at >= ?\n group by integration_id, (created_at at time zone 'UTC')::date`,\n [scope.organizationId, scope.tenantId, ...integrationIds, windowStartUtc],\n )\n\n const dayIndex = new Map(dayKeys.map((key, index) => [key, index]))\n for (const row of dailyRows) {\n const entry = result.get(row.integration_id)\n if (!entry) continue\n const dayKey = typeof row.day === 'string' ? row.day : row.day.toISOString().slice(0, 10)\n const idx = dayIndex.get(dayKey)\n if (idx === undefined) continue\n entry.dailyCounts[idx] = Number(row.cnt)\n }\n\n return result\n },\n }\n}\n\nexport type IntegrationLogService = ReturnType<typeof createIntegrationLogService>\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { groupableCode } from '@open-mercato/shared/lib/telemetry/error-code'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport type { ListIntegrationLogsQuery } from '../data/validators'\nimport { IntegrationLog } from '../data/entities'\n\nconst logger = createLogger('integrations')\n\n/**\n * The error an `integration_logs` row at `level: 'error'` is reported as.\n *\n * A named type rather than a bare `Error` because every integration failure in\n * the product funnels through one line below: backends that fingerprint on the\n * error class would otherwise group these together with unrelated framework\n * errors. The per-row `code` is what separates them from each other.\n */\nexport class IntegrationLogError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'IntegrationLogError'\n }\n}\n\nexport type IntegrationLogAnalytics = {\n lastActivityAt: string | null\n totalCount: number\n errorCount: number\n errorRate: number\n dailyCounts: number[]\n}\n\nfunction startOfUtcDay(d: Date): Date {\n return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))\n}\n\nfunction buildUtcDayKeys(windowDays: number): string[] {\n const end = startOfUtcDay(new Date())\n const keys: string[] = []\n for (let offset = windowDays - 1; offset >= 0; offset -= 1) {\n const t = new Date(end)\n t.setUTCDate(t.getUTCDate() - offset)\n keys.push(t.toISOString().slice(0, 10))\n }\n return keys\n}\n\ntype LogInput = {\n integrationId: string\n runId?: string | null\n scopeEntityType?: string | null\n scopeEntityId?: string | null\n level: 'info' | 'warn' | 'error'\n message: string\n code?: string | null\n payload?: Record<string, unknown> | null\n}\n\n/**\n * Report an error row outward, so recording it is also reporting it.\n *\n * The row is the durable record; this is the signal. Message, `code` and opaque\n * ids only \u2014 `payload` carries the failed item itself and MUST NOT leave the\n * database. Wrapped because observability may never alter behaviour: the row is\n * already flushed by the time this runs, and a telemetry fault degrades to a\n * warning rather than failing the write its caller depends on.\n *\n * The row's own `code` is free-form and written by any module that resolves this\n * service, third-party ones included, so it is narrowed to a fingerprint before\n * it is reported: a code the backend cannot group on is worth less than the\n * catch-all every integration error already shares.\n */\nfunction reportErrorLog(input: LogInput, scope: IntegrationScope): void {\n try {\n getTelemetryRuntime()?.reportError(new IntegrationLogError(input.message), {\n module: 'integrations',\n code: groupableCode(input.code, 'integrations.log_error'),\n attributes: {\n integrationId: input.integrationId,\n runId: input.runId ?? undefined,\n scopeEntityType: input.scopeEntityType ?? undefined,\n scopeEntityId: input.scopeEntityId ?? undefined,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n })\n } catch (telemetryError) {\n logger.warn('Failed to report an integration error log to telemetry', {\n integrationId: input.integrationId,\n err: telemetryError as Error,\n })\n }\n}\n\nexport function createIntegrationLogService(em: EntityManager) {\n return {\n async write(input: LogInput, scope: IntegrationScope): Promise<IntegrationLog> {\n const row = em.create(IntegrationLog, {\n integrationId: input.integrationId,\n runId: input.runId,\n scopeEntityType: input.scopeEntityType,\n scopeEntityId: input.scopeEntityId,\n level: input.level,\n message: input.message,\n code: input.code,\n payload: input.payload,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n })\n await em.persist(row).flush()\n if (input.level === 'error') reportErrorLog(input, scope)\n return row\n },\n\n scoped(integrationId: string, scope: IntegrationScope) {\n return {\n info: (message: string, payload?: Record<string, unknown>) => this.write({ integrationId, level: 'info', message, payload }, scope),\n warn: (message: string, payload?: Record<string, unknown>) => this.write({ integrationId, level: 'warn', message, payload }, scope),\n error: (message: string, payload?: Record<string, unknown>) => this.write({ integrationId, level: 'error', message, payload }, scope),\n }\n },\n\n async query(query: ListIntegrationLogsQuery, scope: IntegrationScope): Promise<{ items: IntegrationLog[]; total: number }> {\n const where: FilterQuery<IntegrationLog> = {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n }\n\n if (query.integrationId) where.integrationId = query.integrationId\n if (query.level) where.level = query.level\n if (query.runId) where.runId = query.runId\n if (query.entityType) where.scopeEntityType = query.entityType\n if (query.entityId) where.scopeEntityId = query.entityId\n\n const items = await findWithDecryption(\n em,\n IntegrationLog,\n where,\n {\n orderBy: { createdAt: 'DESC' },\n limit: query.pageSize,\n offset: (query.page - 1) * query.pageSize,\n },\n scope,\n )\n const total = await em.count(IntegrationLog, where)\n return { items, total }\n },\n\n async pruneOlderThan(days: number, scope: IntegrationScope): Promise<number> {\n const threshold = new Date(Date.now() - days * 24 * 60 * 60 * 1000)\n const deletedCount = await em.nativeDelete(IntegrationLog, {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n createdAt: { $lt: threshold },\n })\n return deletedCount\n },\n\n async aggregateAnalytics(\n integrationIds: string[],\n scope: IntegrationScope,\n windowDays = 30,\n ): Promise<Map<string, IntegrationLogAnalytics>> {\n const dayKeys = buildUtcDayKeys(windowDays)\n const windowStartUtc = `${dayKeys[0]}T00:00:00.000Z`\n\n const empty = (): IntegrationLogAnalytics => ({\n lastActivityAt: null,\n totalCount: 0,\n errorCount: 0,\n errorRate: 0,\n dailyCounts: dayKeys.map(() => 0),\n })\n\n const result = new Map<string, IntegrationLogAnalytics>()\n for (const id of integrationIds) {\n result.set(id, empty())\n }\n if (integrationIds.length === 0) {\n return result\n }\n\n const conn = em.getConnection()\n const inList = integrationIds.map(() => '?').join(', ')\n\n type AggRow = {\n integration_id: string\n last_activity: Date | string | null\n total_count: string | number\n error_count: string | number | null\n }\n\n const aggRows = await conn.execute<AggRow[]>(\n `select integration_id,\n max(created_at) as last_activity,\n count(*)::int as total_count,\n coalesce(sum(case when level = 'error' then 1 else 0 end), 0)::int as error_count\n from integration_logs\n where organization_id = ? and tenant_id = ?\n and integration_id in (${inList})\n and created_at >= ?\n group by integration_id`,\n [scope.organizationId, scope.tenantId, ...integrationIds, windowStartUtc],\n )\n\n for (const row of aggRows) {\n const entry = result.get(row.integration_id)\n if (!entry) continue\n const total = Number(row.total_count)\n const errors = Number(row.error_count ?? 0)\n entry.totalCount = total\n entry.errorCount = errors\n entry.errorRate = total > 0 ? errors / total : 0\n if (row.last_activity) {\n const d = row.last_activity instanceof Date ? row.last_activity : new Date(row.last_activity)\n entry.lastActivityAt = d.toISOString()\n }\n }\n\n type DailyRow = { integration_id: string; day: string | Date; cnt: string | number }\n\n const dailyRows = await conn.execute<DailyRow[]>(\n `select integration_id,\n (created_at at time zone 'UTC')::date::text as day,\n count(*)::int as cnt\n from integration_logs\n where organization_id = ? and tenant_id = ?\n and integration_id in (${inList})\n and created_at >= ?\n group by integration_id, (created_at at time zone 'UTC')::date`,\n [scope.organizationId, scope.tenantId, ...integrationIds, windowStartUtc],\n )\n\n const dayIndex = new Map(dayKeys.map((key, index) => [key, index]))\n for (const row of dailyRows) {\n const entry = result.get(row.integration_id)\n if (!entry) continue\n const dayKey = typeof row.day === 'string' ? row.day : row.day.toISOString().slice(0, 10)\n const idx = dayIndex.get(dayKey)\n if (idx === undefined) continue\n entry.dailyCounts[idx] = Number(row.cnt)\n }\n\n return result\n },\n }\n}\n\nexport type IntegrationLogService = ReturnType<typeof createIntegrationLogService>\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -25,7 +25,11 @@ async function handle(job, ctx) {
25
25
  scopeEntityType: "payment_transaction",
26
26
  scopeEntityId: transaction.id,
27
27
  level: "error",
28
- message: "Payment status polling failed",
28
+ // The cause belongs in the message, not only in `payload`: the payload is
29
+ // the durable record and never leaves the database, so an operator paged by
30
+ // the reported error would otherwise learn only that a gateway failed.
31
+ message: `Payment status polling failed: ${message}`,
32
+ code: "payment_gateways.status_poll_failed",
29
33
  payload: {
30
34
  transactionId: transaction.id,
31
35
  message
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/payment_gateways/workers/status-poller.ts"],
4
- "sourcesContent": ["import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { IntegrationLogService } from '../../integrations/lib/log-service'\nimport type { PaymentGatewayService } from '../lib/gateway-service'\n\ntype PollerJobPayload = {\n scope?: {\n organizationId?: string\n tenantId?: string\n providerKey?: string\n }\n limit?: number\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport const metadata: WorkerMeta = {\n queue: 'payment-gateways-status-poller',\n id: 'payment-gateways:status-poller',\n concurrency: 2,\n}\n\nexport default async function handle(job: QueuedJob<PollerJobPayload>, ctx: HandlerContext): Promise<void> {\n const service = ctx.resolve<PaymentGatewayService>('paymentGatewayService')\n const integrationLogService = ctx.resolve<IntegrationLogService>('integrationLogService')\n const transactions = await service.listTransactionsForStatusPolling({\n organizationId: job.payload.scope?.organizationId,\n tenantId: job.payload.scope?.tenantId,\n providerKey: job.payload.scope?.providerKey,\n limit: job.payload.limit ?? 100,\n })\n\n for (const transaction of transactions) {\n try {\n await service.getPaymentStatus(transaction.id, {\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : 'Unknown polling error'\n await integrationLogService.write({\n integrationId: `gateway_${transaction.providerKey}`,\n scopeEntityType: 'payment_transaction',\n scopeEntityId: transaction.id,\n level: 'error',\n message: 'Payment status polling failed',\n payload: {\n transactionId: transaction.id,\n message,\n },\n }, {\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n }\n }\n}\n"],
5
- "mappings": "AAiBO,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAEA,eAAO,OAA8B,KAAkC,KAAoC;AACzG,QAAM,UAAU,IAAI,QAA+B,uBAAuB;AAC1E,QAAM,wBAAwB,IAAI,QAA+B,uBAAuB;AACxF,QAAM,eAAe,MAAM,QAAQ,iCAAiC;AAAA,IAClE,gBAAgB,IAAI,QAAQ,OAAO;AAAA,IACnC,UAAU,IAAI,QAAQ,OAAO;AAAA,IAC7B,aAAa,IAAI,QAAQ,OAAO;AAAA,IAChC,OAAO,IAAI,QAAQ,SAAS;AAAA,EAC9B,CAAC;AAED,aAAW,eAAe,cAAc;AACtC,QAAI;AACF,YAAM,QAAQ,iBAAiB,YAAY,IAAI;AAAA,QAC7C,gBAAgB,YAAY;AAAA,QAC5B,UAAU,YAAY;AAAA,MACxB,CAAC;AAAA,IACH,SAAS,OAAgB;AACvB,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,YAAM,sBAAsB,MAAM;AAAA,QAChC,eAAe,WAAW,YAAY,WAAW;AAAA,QACjD,iBAAiB;AAAA,QACjB,eAAe,YAAY;AAAA,QAC3B,OAAO;AAAA,QACP,SAAS;AAAA,QACT,SAAS;AAAA,UACP,eAAe,YAAY;AAAA,UAC3B;AAAA,QACF;AAAA,MACF,GAAG;AAAA,QACD,gBAAgB,YAAY;AAAA,QAC5B,UAAU,YAAY;AAAA,MACxB,CAAC;AAAA,IACH;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { IntegrationLogService } from '../../integrations/lib/log-service'\nimport type { PaymentGatewayService } from '../lib/gateway-service'\n\ntype PollerJobPayload = {\n scope?: {\n organizationId?: string\n tenantId?: string\n providerKey?: string\n }\n limit?: number\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport const metadata: WorkerMeta = {\n queue: 'payment-gateways-status-poller',\n id: 'payment-gateways:status-poller',\n concurrency: 2,\n}\n\nexport default async function handle(job: QueuedJob<PollerJobPayload>, ctx: HandlerContext): Promise<void> {\n const service = ctx.resolve<PaymentGatewayService>('paymentGatewayService')\n const integrationLogService = ctx.resolve<IntegrationLogService>('integrationLogService')\n const transactions = await service.listTransactionsForStatusPolling({\n organizationId: job.payload.scope?.organizationId,\n tenantId: job.payload.scope?.tenantId,\n providerKey: job.payload.scope?.providerKey,\n limit: job.payload.limit ?? 100,\n })\n\n for (const transaction of transactions) {\n try {\n await service.getPaymentStatus(transaction.id, {\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : 'Unknown polling error'\n await integrationLogService.write({\n integrationId: `gateway_${transaction.providerKey}`,\n scopeEntityType: 'payment_transaction',\n scopeEntityId: transaction.id,\n level: 'error',\n // The cause belongs in the message, not only in `payload`: the payload is\n // the durable record and never leaves the database, so an operator paged by\n // the reported error would otherwise learn only that a gateway failed.\n message: `Payment status polling failed: ${message}`,\n code: 'payment_gateways.status_poll_failed',\n payload: {\n transactionId: transaction.id,\n message,\n },\n }, {\n organizationId: transaction.organizationId,\n tenantId: transaction.tenantId,\n })\n }\n }\n}\n"],
5
+ "mappings": "AAiBO,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAEA,eAAO,OAA8B,KAAkC,KAAoC;AACzG,QAAM,UAAU,IAAI,QAA+B,uBAAuB;AAC1E,QAAM,wBAAwB,IAAI,QAA+B,uBAAuB;AACxF,QAAM,eAAe,MAAM,QAAQ,iCAAiC;AAAA,IAClE,gBAAgB,IAAI,QAAQ,OAAO;AAAA,IACnC,UAAU,IAAI,QAAQ,OAAO;AAAA,IAC7B,aAAa,IAAI,QAAQ,OAAO;AAAA,IAChC,OAAO,IAAI,QAAQ,SAAS;AAAA,EAC9B,CAAC;AAED,aAAW,eAAe,cAAc;AACtC,QAAI;AACF,YAAM,QAAQ,iBAAiB,YAAY,IAAI;AAAA,QAC7C,gBAAgB,YAAY;AAAA,QAC5B,UAAU,YAAY;AAAA,MACxB,CAAC;AAAA,IACH,SAAS,OAAgB;AACvB,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,YAAM,sBAAsB,MAAM;AAAA,QAChC,eAAe,WAAW,YAAY,WAAW;AAAA,QACjD,iBAAiB;AAAA,QACjB,eAAe,YAAY;AAAA,QAC3B,OAAO;AAAA;AAAA;AAAA;AAAA,QAIP,SAAS,kCAAkC,OAAO;AAAA,QAClD,MAAM;AAAA,QACN,SAAS;AAAA,UACP,eAAe,YAAY;AAAA,UAC3B;AAAA,QACF;AAAA,MACF,GAAG;AAAA,QACD,gBAAgB,YAAY;AAAA,QAC5B,UAAU,YAAY;AAAA,MACxB,CAAC;AAAA,IACH;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -5,6 +5,8 @@ import {
5
5
  resolveSearchTokenLimits
6
6
  } from "@open-mercato/shared/lib/search/config";
7
7
  import { tokenizeText } from "@open-mercato/shared/lib/search/tokenize";
8
+ import { looksLikeEncryptedPayload } from "@open-mercato/shared/lib/encryption/aes";
9
+ import { createKmsService, resolveEncryptionMode } from "@open-mercato/shared/lib/encryption/kms";
8
10
  import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
9
11
  import { createLogger } from "@open-mercato/shared/lib/logger";
10
12
  const logger = createLogger("query_index").child({ component: "search-tokens" });
@@ -37,6 +39,35 @@ function collectTextValues(value) {
37
39
  }
38
40
  return [];
39
41
  }
42
+ let guardKmsService = null;
43
+ function shouldGuardCiphertext() {
44
+ guardKmsService ??= createKmsService();
45
+ return resolveEncryptionMode(guardKmsService) !== "active";
46
+ }
47
+ function ciphertextFieldsOf(doc, guard) {
48
+ const fields = /* @__PURE__ */ new Set();
49
+ if (!guard || !doc) return fields;
50
+ for (const [field, value] of Object.entries(doc)) {
51
+ const values = collectTextValues(value);
52
+ if (values.length && values.some((text) => looksLikeEncryptedPayload(text))) fields.add(field);
53
+ }
54
+ return fields;
55
+ }
56
+ const warnedCiphertextEntities = /* @__PURE__ */ new Set();
57
+ function warnCiphertextSkipped(entityType, tenantId, fields) {
58
+ if (!fields.size) return;
59
+ const key = `${entityType}|${tenantId ?? ""}`;
60
+ if (warnedCiphertextEntities.has(key)) return;
61
+ warnedCiphertextEntities.add(key);
62
+ logger.warn(
63
+ "Search indexing skipped ciphertext fields and preserved their existing tokens. This means TENANT_DATA_ENCRYPTION was switched off while encrypted data was still at rest. Run `mercato entities decrypt-database` and reindex; until then these fields are not searchable.",
64
+ { entityType, tenantId, fields: Array.from(fields).sort((left, right) => left.localeCompare(right)) }
65
+ );
66
+ }
67
+ function resetCiphertextGuardState() {
68
+ warnedCiphertextEntities.clear();
69
+ guardKmsService = null;
70
+ }
40
71
  function shouldIndexField(field, value, config, entityType) {
41
72
  if (typeof value !== "string" && !Array.isArray(value)) return false;
42
73
  const lower = field.toLowerCase();
@@ -60,8 +91,11 @@ function buildSearchTokenRows(params) {
60
91
  const limits = resolveSearchTokenLimits(config);
61
92
  const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY;
62
93
  const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY;
94
+ const ciphertextFields = ciphertextFieldsOf(params.doc, params.guardCiphertext ?? shouldGuardCiphertext());
95
+ warnCiphertextSkipped(params.entityType, scope.tenantId, ciphertextFields);
63
96
  for (const [field, rawValue] of Object.entries(params.doc)) {
64
97
  if (tokens.length >= recordLimit) break;
98
+ if (ciphertextFields.has(field)) continue;
65
99
  if (!shouldIndexField(field, rawValue, config, params.entityType)) continue;
66
100
  const values = collectTextValues(rawValue);
67
101
  const seen = /* @__PURE__ */ new Set();
@@ -107,11 +141,12 @@ function buildSearchTokenRows(params) {
107
141
  debug("doc.completed", { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length });
108
142
  return tokens;
109
143
  }
110
- function buildFieldPairs(recordId, doc) {
144
+ function buildFieldPairs(recordId, doc, skipFields) {
111
145
  if (!doc) return [];
112
146
  const pairs = [];
113
147
  const dedupe = /* @__PURE__ */ new Set();
114
148
  for (const field of Object.keys(doc)) {
149
+ if (skipFields?.has(field)) continue;
115
150
  const key = `${recordId}|${field}`;
116
151
  if (dedupe.has(key)) continue;
117
152
  dedupe.add(key);
@@ -156,12 +191,18 @@ function tallyTokenRows(rows, keyOf) {
156
191
  return tallies;
157
192
  }
158
193
  async function replaceSearchTokensForRecord(db, params, options) {
159
- const rows = buildSearchTokenRows(params);
194
+ const guardCiphertext = params.guardCiphertext ?? shouldGuardCiphertext();
195
+ const rows = buildSearchTokenRows({ ...params, guardCiphertext });
160
196
  const config = params.config ?? resolveSearchConfig();
161
197
  if (!config.enabled) return;
162
198
  const organizationId = params.organizationId ?? null;
163
199
  const tenantId = params.tenantId ?? null;
164
- const fieldPairs = buildFieldPairs(String(params.recordId), params.doc);
200
+ const ciphertextFields = ciphertextFieldsOf(params.doc, guardCiphertext);
201
+ const fieldPairs = buildFieldPairs(String(params.recordId), params.doc, ciphertextFields);
202
+ if (params.doc && ciphertextFields.size && !fieldPairs.length) {
203
+ debug("record.preserve-ciphertext", { entityType: params.entityType, recordId: params.recordId });
204
+ return;
205
+ }
165
206
  const scopeTokenQuery = (query) => {
166
207
  let scoped = query.where("entity_type", "=", params.entityType).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`).where("entity_id", "=", String(params.recordId));
167
208
  if (fieldPairs.length) {
@@ -216,11 +257,21 @@ async function deleteSearchTokensForRecord(db, params, options) {
216
257
  const executor = options?.trx ?? db;
217
258
  await executor.deleteFrom("search_tokens").where("entity_type", "=", params.entityType).where("entity_id", "=", String(params.recordId)).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`).execute();
218
259
  }
219
- async function replaceSearchTokensForBatch(db, payloads) {
220
- if (!payloads.length) return;
260
+ async function replaceSearchTokensForBatch(db, allPayloads) {
261
+ if (!allPayloads.length) return;
221
262
  const config = resolveSearchConfig();
222
263
  if (!config.enabled) return;
223
- const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }));
264
+ const guardCiphertext = shouldGuardCiphertext();
265
+ const preservedRecordIds = /* @__PURE__ */ new Set();
266
+ const payloads = allPayloads.filter((payload) => {
267
+ const ciphertextFields = ciphertextFieldsOf(payload.doc, guardCiphertext);
268
+ if (!ciphertextFields.size) return true;
269
+ warnCiphertextSkipped(payload.entityType, payload.tenantId ?? null, ciphertextFields);
270
+ preservedRecordIds.add(String(payload.recordId));
271
+ return false;
272
+ });
273
+ if (!payloads.length) return;
274
+ const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config, guardCiphertext }));
224
275
  if (!rows.length) {
225
276
  const entityType = payloads[0]?.entityType;
226
277
  if (!entityType) return;
@@ -281,7 +332,8 @@ async function replaceSearchTokensForBatch(db, payloads) {
281
332
  debug("batch.skip", {
282
333
  entityType: payloads[0].entityType,
283
334
  recordCount: payloads.length,
284
- changedCount: changedRecordKeys.size
335
+ changedCount: changedRecordKeys.size,
336
+ preservedCiphertextRecordCount: preservedRecordIds.size
285
337
  });
286
338
  if (!changedRecordKeys.size) return;
287
339
  await db.transaction().execute(async (trx) => {
@@ -302,6 +354,7 @@ export {
302
354
  deleteSearchTokensForRecord,
303
355
  isSearchDebugEnabled,
304
356
  replaceSearchTokensForBatch,
305
- replaceSearchTokensForRecord
357
+ replaceSearchTokensForRecord,
358
+ resetCiphertextGuardState
306
359
  };
307
360
  //# sourceMappingURL=search-tokens.js.map