@open-mercato/shared 0.6.6-develop.6429.1.2fba7258d1 → 0.6.6-develop.6450.1.b493fb430c

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.
@@ -83,6 +83,11 @@ function ensureOrganizationScope(ctx, organizationId) {
83
83
  }
84
84
  return;
85
85
  }
86
+ if (!scope.tenantId && organizationId && ctx.auth && !isSuperAdmin) {
87
+ const currentOrg2 = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
88
+ logScopeViolation(ctx, organizationId, currentOrg2);
89
+ throw new CrudHttpError(403, { error: "Forbidden" });
90
+ }
86
91
  if (isOrganizationAccessAllowed({
87
92
  isSuperAdmin,
88
93
  allowedOrganizationIds: scope.allowedIds,
@@ -95,8 +100,16 @@ function ensureOrganizationScope(ctx, organizationId) {
95
100
  throw new CrudHttpError(403, { error: "Forbidden" });
96
101
  }
97
102
  function ensureTenantScope(ctx, tenantId) {
103
+ const isSuperAdmin = ctx.auth?.isSuperAdmin === true;
98
104
  const currentTenant = ctx.auth?.tenantId ?? null;
99
- if (currentTenant && currentTenant !== tenantId) {
105
+ if (!currentTenant) {
106
+ if (tenantId && ctx.auth && !isSuperAdmin) {
107
+ logTenantScopeViolation(ctx, tenantId, currentTenant);
108
+ throw new CrudHttpError(403, { error: "Forbidden" });
109
+ }
110
+ return;
111
+ }
112
+ if (currentTenant !== tenantId) {
100
113
  logTenantScopeViolation(ctx, tenantId, currentTenant);
101
114
  throw new CrudHttpError(403, { error: "Forbidden" });
102
115
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/commands/scope.ts"],
4
- "sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { env } from 'process'\n\nfunction buildScopeLogContext(ctx: CommandRuntimeContext) {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n return {\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n }\n}\n\nfunction isStrictOrganizationScopeEnforced(): boolean {\n return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)\n}\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Unscoped organization command executed without organization context', {\n targetOrganizationId: organizationId,\n strictEnforcement: isStrictOrganizationScopeEnforced(),\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg) {\n if (currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n // No current org could be resolved either. This branch previously returned\n // with no validation and no signal \u2014 a fail-open-by-omission shape (#2441):\n // a new command path reaching here with `organizationScope: null` would act\n // on an arbitrary target org silently. Preserve the legacy allow behavior by\n // default (the path is load-bearing) but make the unscoped access observable,\n // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.\n if (organizationId) {\n logUnscopedOrganizationAccess(ctx, organizationId)\n if (isStrictOrganizationScopeEnforced()) {\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n }\n return\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const currentTenant = ctx.auth?.tenantId ?? null\n if (currentTenant && currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { env } from 'process'\n\nfunction buildScopeLogContext(ctx: CommandRuntimeContext) {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n return {\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n }\n}\n\nfunction isStrictOrganizationScopeEnforced(): boolean {\n return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)\n}\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Unscoped organization command executed without organization context', {\n targetOrganizationId: organizationId,\n strictEnforcement: isStrictOrganizationScopeEnforced(),\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg) {\n if (currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n // No current org could be resolved either. This branch previously returned\n // with no validation and no signal \u2014 a fail-open-by-omission shape (#2441):\n // a new command path reaching here with `organizationScope: null` would act\n // on an arbitrary target org silently. Preserve the legacy allow behavior by\n // default (the path is load-bearing) but make the unscoped access observable,\n // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.\n if (organizationId) {\n logUnscopedOrganizationAccess(ctx, organizationId)\n if (isStrictOrganizationScopeEnforced()) {\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n }\n return\n }\n\n if (!scope.tenantId && organizationId && ctx.auth && !isSuperAdmin) {\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const currentTenant = ctx.auth?.tenantId ?? null\n if (!currentTenant) {\n if (tenantId && ctx.auth && !isSuperAdmin) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n if (currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["currentOrg"]
7
7
  }
@@ -18,6 +18,19 @@ function escapeCsv(value) {
18
18
  }
19
19
  return value;
20
20
  }
21
+ function neutralizeSpreadsheetFormula(value) {
22
+ if (/^[=+\-@\t\r\n]/.test(value)) {
23
+ return `'${value}`;
24
+ }
25
+ return value;
26
+ }
27
+ function normalizeCsvValue(value) {
28
+ const normalized = normalizeValue(value);
29
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
30
+ return normalized;
31
+ }
32
+ return neutralizeSpreadsheetFormula(normalized);
33
+ }
21
34
  function escapeMarkdown(value) {
22
35
  const escaped = value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br />");
23
36
  return escaped || " ";
@@ -38,7 +51,7 @@ function serializeCsv(prepared) {
38
51
  const lines = [headers.join(",")];
39
52
  for (const row of prepared.rows) {
40
53
  const values = prepared.columns.map((col) => {
41
- const raw = normalizeValue(row[col.field]);
54
+ const raw = normalizeCsvValue(row[col.field]);
42
55
  return escapeCsv(raw);
43
56
  });
44
57
  lines.push(values.join(","));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/crud/exporters.ts"],
4
- "sourcesContent": ["export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'\n\nexport type CrudExportColumn = {\n field: string\n header: string\n}\n\nexport type PreparedExport = {\n columns: CrudExportColumn[]\n rows: Array<Record<string, unknown>>\n}\n\nexport type SerializedExport = {\n body: string\n contentType: string\n fileExtension: string\n}\n\nconst CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'\nconst JSON_CONTENT_TYPE = 'application/json; charset=utf-8'\nconst XML_CONTENT_TYPE = 'application/xml; charset=utf-8'\nconst MARKDOWN_CONTENT_TYPE = 'text/markdown; charset=utf-8'\n\nfunction normalizeValue(value: unknown): string {\n if (value == null) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'bigint') return value.toString()\n if (typeof value === 'object') {\n if (Array.isArray(value)) return value.map((v) => normalizeValue(v)).filter(Boolean).join(', ')\n return JSON.stringify(value)\n }\n return String(value)\n}\n\nfunction escapeCsv(value: string): string {\n if (/[\",\\n\\r]/.test(value)) {\n return `\"${value.replace(/\"/g, '\"\"')}\"`\n }\n return value\n}\n\nfunction escapeMarkdown(value: string): string {\n const escaped = value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\\|/g, '\\\\|')\n .replace(/\\r?\\n/g, '<br />')\n return escaped || ' '\n}\n\nfunction escapeXmlTag(tag: string, fallbackIndex: number): string {\n const sanitized = tag.replace(/[^A-Za-z0-9_:-]/g, '_')\n const normalized = sanitized.length > 0 ? sanitized : `field_${fallbackIndex}`\n if (/^[^A-Za-z_]/.test(normalized)) {\n return `f_${normalized}`\n }\n return normalized\n}\n\nfunction escapeXmlValue(value: string): string {\n return value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;')\n}\n\nfunction serializeCsv(prepared: PreparedExport): SerializedExport {\n const headers = prepared.columns.map((col) => col.header)\n const lines = [headers.join(',')]\n for (const row of prepared.rows) {\n const values = prepared.columns.map((col) => {\n const raw = normalizeValue(row[col.field])\n return escapeCsv(raw)\n })\n lines.push(values.join(','))\n }\n return {\n body: lines.join('\\n'),\n contentType: CSV_CONTENT_TYPE,\n fileExtension: 'csv',\n }\n}\n\nfunction serializeJson(prepared: PreparedExport): SerializedExport {\n // For JSON we return objects keyed by exported field names with human headers as metadata\n const payload = prepared.rows.map((row) => {\n const obj: Record<string, unknown> = {}\n for (const column of prepared.columns) {\n obj[column.header] = row[column.field]\n }\n return obj\n })\n return {\n body: JSON.stringify(payload, null, 2),\n contentType: JSON_CONTENT_TYPE,\n fileExtension: 'json',\n }\n}\n\nfunction serializeXml(prepared: PreparedExport): SerializedExport {\n const lines: string[] = ['<?xml version=\"1.0\" encoding=\"UTF-8\"?>', '<records>']\n for (const row of prepared.rows) {\n lines.push(' <record>')\n prepared.columns.forEach((column, index) => {\n const tag = escapeXmlTag(column.field || column.header, index)\n const value = escapeXmlValue(normalizeValue(row[column.field]))\n lines.push(` <${tag}>${value}</${tag}>`)\n })\n lines.push(' </record>')\n }\n lines.push('</records>')\n return {\n body: lines.join('\\n'),\n contentType: XML_CONTENT_TYPE,\n fileExtension: 'xml',\n }\n}\n\nfunction serializeMarkdown(prepared: PreparedExport): SerializedExport {\n const headers = prepared.columns.map((col) => escapeMarkdown(col.header))\n const headerLine = `| ${headers.join(' | ')} |`\n const dividerLine = `| ${prepared.columns.map(() => '---').join(' | ')} |`\n const rows = prepared.rows.map((row) => {\n const cells = prepared.columns.map((col) => escapeMarkdown(normalizeValue(row[col.field])))\n return `| ${cells.join(' | ')} |`\n })\n const body = [headerLine, dividerLine, ...rows].join('\\n')\n return {\n body,\n contentType: MARKDOWN_CONTENT_TYPE,\n fileExtension: 'md',\n }\n}\n\nexport function serializeExport(prepared: PreparedExport, format: CrudExportFormat): SerializedExport {\n switch (format) {\n case 'csv':\n return serializeCsv(prepared)\n case 'json':\n return serializeJson(prepared)\n case 'xml':\n return serializeXml(prepared)\n case 'markdown':\n return serializeMarkdown(prepared)\n default:\n return serializeJson(prepared)\n }\n}\n\nexport function normalizeExportFormat(raw: unknown): CrudExportFormat | null {\n if (typeof raw !== 'string' || raw.trim() === '') return null\n const value = raw.toLowerCase()\n if (value === 'csv') return 'csv'\n if (value === 'json' || value === 'application/json') return 'json'\n if (value === 'xml' || value === 'application/xml') return 'xml'\n if (value === 'markdown' || value === 'md' || value === 'text/markdown') return 'markdown'\n return null\n}\n\nexport function defaultExportFilename(base: string | undefined | null, format: CrudExportFormat): string {\n const safeBase = (base && base.trim().length > 0 ? base.trim() : 'export')\n .replace(/[^a-z0-9_\\-]/gi, '_')\n const suffix = format === 'markdown' ? 'md' : format\n return `${safeBase}.${suffix}`\n}\n\nexport function toHeaderLabel(key: string): string {\n const withoutPrefix = key.replace(/^cf[:_\\-\\s]+/i, '')\n const normalized = withoutPrefix.replace(/[_\\-\\s]+/g, ' ').trim()\n if (!normalized) return 'Field'\n return normalized.replace(/\\b\\w/g, (char) => char.toUpperCase())\n}\n\nexport function ensureColumns(rows: Array<Record<string, unknown>>, hint?: CrudExportColumn[]): CrudExportColumn[] {\n if (hint && hint.length > 0) return hint\n const used = new Map<string, string>()\n rows.forEach((row) => {\n Object.keys(row || {}).forEach((key) => {\n if (!used.has(key)) used.set(key, key)\n })\n })\n if (used.size === 0) return [{ field: 'id', header: 'ID' }]\n return Array.from(used.keys()).map((key) => ({\n field: key,\n header: toHeaderLabel(key),\n }))\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'\n\nexport type CrudExportColumn = {\n field: string\n header: string\n}\n\nexport type PreparedExport = {\n columns: CrudExportColumn[]\n rows: Array<Record<string, unknown>>\n}\n\nexport type SerializedExport = {\n body: string\n contentType: string\n fileExtension: string\n}\n\nconst CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'\nconst JSON_CONTENT_TYPE = 'application/json; charset=utf-8'\nconst XML_CONTENT_TYPE = 'application/xml; charset=utf-8'\nconst MARKDOWN_CONTENT_TYPE = 'text/markdown; charset=utf-8'\n\nfunction normalizeValue(value: unknown): string {\n if (value == null) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'bigint') return value.toString()\n if (typeof value === 'object') {\n if (Array.isArray(value)) return value.map((v) => normalizeValue(v)).filter(Boolean).join(', ')\n return JSON.stringify(value)\n }\n return String(value)\n}\n\nfunction escapeCsv(value: string): string {\n if (/[\",\\n\\r]/.test(value)) {\n return `\"${value.replace(/\"/g, '\"\"')}\"`\n }\n return value\n}\n\nfunction neutralizeSpreadsheetFormula(value: string): string {\n if (/^[=+\\-@\\t\\r\\n]/.test(value)) {\n return `'${value}`\n }\n return value\n}\n\nfunction normalizeCsvValue(value: unknown): string {\n const normalized = normalizeValue(value)\n if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {\n return normalized\n }\n return neutralizeSpreadsheetFormula(normalized)\n}\n\nfunction escapeMarkdown(value: string): string {\n const escaped = value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\\|/g, '\\\\|')\n .replace(/\\r?\\n/g, '<br />')\n return escaped || ' '\n}\n\nfunction escapeXmlTag(tag: string, fallbackIndex: number): string {\n const sanitized = tag.replace(/[^A-Za-z0-9_:-]/g, '_')\n const normalized = sanitized.length > 0 ? sanitized : `field_${fallbackIndex}`\n if (/^[^A-Za-z_]/.test(normalized)) {\n return `f_${normalized}`\n }\n return normalized\n}\n\nfunction escapeXmlValue(value: string): string {\n return value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;')\n}\n\nfunction serializeCsv(prepared: PreparedExport): SerializedExport {\n const headers = prepared.columns.map((col) => col.header)\n const lines = [headers.join(',')]\n for (const row of prepared.rows) {\n const values = prepared.columns.map((col) => {\n const raw = normalizeCsvValue(row[col.field])\n return escapeCsv(raw)\n })\n lines.push(values.join(','))\n }\n return {\n body: lines.join('\\n'),\n contentType: CSV_CONTENT_TYPE,\n fileExtension: 'csv',\n }\n}\n\nfunction serializeJson(prepared: PreparedExport): SerializedExport {\n // For JSON we return objects keyed by exported field names with human headers as metadata\n const payload = prepared.rows.map((row) => {\n const obj: Record<string, unknown> = {}\n for (const column of prepared.columns) {\n obj[column.header] = row[column.field]\n }\n return obj\n })\n return {\n body: JSON.stringify(payload, null, 2),\n contentType: JSON_CONTENT_TYPE,\n fileExtension: 'json',\n }\n}\n\nfunction serializeXml(prepared: PreparedExport): SerializedExport {\n const lines: string[] = ['<?xml version=\"1.0\" encoding=\"UTF-8\"?>', '<records>']\n for (const row of prepared.rows) {\n lines.push(' <record>')\n prepared.columns.forEach((column, index) => {\n const tag = escapeXmlTag(column.field || column.header, index)\n const value = escapeXmlValue(normalizeValue(row[column.field]))\n lines.push(` <${tag}>${value}</${tag}>`)\n })\n lines.push(' </record>')\n }\n lines.push('</records>')\n return {\n body: lines.join('\\n'),\n contentType: XML_CONTENT_TYPE,\n fileExtension: 'xml',\n }\n}\n\nfunction serializeMarkdown(prepared: PreparedExport): SerializedExport {\n const headers = prepared.columns.map((col) => escapeMarkdown(col.header))\n const headerLine = `| ${headers.join(' | ')} |`\n const dividerLine = `| ${prepared.columns.map(() => '---').join(' | ')} |`\n const rows = prepared.rows.map((row) => {\n const cells = prepared.columns.map((col) => escapeMarkdown(normalizeValue(row[col.field])))\n return `| ${cells.join(' | ')} |`\n })\n const body = [headerLine, dividerLine, ...rows].join('\\n')\n return {\n body,\n contentType: MARKDOWN_CONTENT_TYPE,\n fileExtension: 'md',\n }\n}\n\nexport function serializeExport(prepared: PreparedExport, format: CrudExportFormat): SerializedExport {\n switch (format) {\n case 'csv':\n return serializeCsv(prepared)\n case 'json':\n return serializeJson(prepared)\n case 'xml':\n return serializeXml(prepared)\n case 'markdown':\n return serializeMarkdown(prepared)\n default:\n return serializeJson(prepared)\n }\n}\n\nexport function normalizeExportFormat(raw: unknown): CrudExportFormat | null {\n if (typeof raw !== 'string' || raw.trim() === '') return null\n const value = raw.toLowerCase()\n if (value === 'csv') return 'csv'\n if (value === 'json' || value === 'application/json') return 'json'\n if (value === 'xml' || value === 'application/xml') return 'xml'\n if (value === 'markdown' || value === 'md' || value === 'text/markdown') return 'markdown'\n return null\n}\n\nexport function defaultExportFilename(base: string | undefined | null, format: CrudExportFormat): string {\n const safeBase = (base && base.trim().length > 0 ? base.trim() : 'export')\n .replace(/[^a-z0-9_\\-]/gi, '_')\n const suffix = format === 'markdown' ? 'md' : format\n return `${safeBase}.${suffix}`\n}\n\nexport function toHeaderLabel(key: string): string {\n const withoutPrefix = key.replace(/^cf[:_\\-\\s]+/i, '')\n const normalized = withoutPrefix.replace(/[_\\-\\s]+/g, ' ').trim()\n if (!normalized) return 'Field'\n return normalized.replace(/\\b\\w/g, (char) => char.toUpperCase())\n}\n\nexport function ensureColumns(rows: Array<Record<string, unknown>>, hint?: CrudExportColumn[]): CrudExportColumn[] {\n if (hint && hint.length > 0) return hint\n const used = new Map<string, string>()\n rows.forEach((row) => {\n Object.keys(row || {}).forEach((key) => {\n if (!used.has(key)) used.set(key, key)\n })\n })\n if (used.size === 0) return [{ field: 'id', header: 'ID' }]\n return Array.from(used.keys()).map((key) => ({\n field: key,\n header: toHeaderLabel(key),\n }))\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,6 @@
1
1
  import { ReferenceKind } from "@mikro-orm/core";
2
2
  import { resolveEntityIdFromMetadata } from "./entityIds.js";
3
+ import { parseDecryptedFieldValue } from "./tenantDataEncryptionService.js";
3
4
  import { isTenantDataEncryptionEnabled } from "./toggles.js";
4
5
  import { isEncryptionDebugEnabled } from "./toggles.js";
5
6
  import { resolveTenantEncryptionService } from "./customFieldValues.js";
@@ -18,6 +19,16 @@ function debug(event, payload) {
18
19
  } catch {
19
20
  }
20
21
  }
22
+ function isJsonColumnProperty(prop) {
23
+ if (!prop || typeof prop !== "object") return false;
24
+ const candidate = prop;
25
+ const type = typeof candidate.type === "string" ? candidate.type.toLowerCase() : "";
26
+ if (type === "json" || type === "jsonb") return true;
27
+ const columnTypes = Array.isArray(candidate.columnTypes) ? candidate.columnTypes : [];
28
+ if (columnTypes.some((value) => typeof value === "string" && value.toLowerCase().includes("json"))) return true;
29
+ const customTypeName = candidate.customType?.constructor?.name;
30
+ return typeof customTypeName === "string" && customTypeName.toLowerCase().includes("json");
31
+ }
21
32
  const registeredEventManagers = /* @__PURE__ */ new WeakSet();
22
33
  const subscribersByService = /* @__PURE__ */ new WeakMap();
23
34
  function getSubscriberForService(service) {
@@ -75,6 +86,17 @@ class TenantEncryptionSubscriber {
75
86
  return null;
76
87
  }
77
88
  }
89
+ restoreDecryptedJsonColumns(target, meta) {
90
+ const properties = meta?.properties;
91
+ if (!properties || typeof properties !== "object") return;
92
+ for (const [propertyName, prop] of Object.entries(properties)) {
93
+ if (!isJsonColumnProperty(prop)) continue;
94
+ const value = target[propertyName];
95
+ if (typeof value !== "string") continue;
96
+ const parsed = parseDecryptedFieldValue(value);
97
+ if (parsed !== value) target[propertyName] = parsed;
98
+ }
99
+ }
78
100
  syncOriginalEntityData(target, meta, em) {
79
101
  const helper = target?.__helper;
80
102
  if (!helper || typeof helper !== "object") return;
@@ -238,6 +260,7 @@ class TenantEncryptionSubscriber {
238
260
  const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em) : false;
239
261
  const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId);
240
262
  Object.assign(target, decrypted);
263
+ this.restoreDecryptedJsonColumns(target, resolvedMeta);
241
264
  if (syncOriginal && !hadPendingChanges) {
242
265
  this.syncOriginalEntityData(target, resolvedMeta, em);
243
266
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/encryption/subscriber.ts"],
4
- "sourcesContent": ["import type { EntityMetadata, EventArgs, EventSubscriber } from '@mikro-orm/core'\nimport { ReferenceKind } from '@mikro-orm/core'\nimport { resolveEntityIdFromMetadata } from './entityIds'\nimport { TenantDataEncryptionService } from './tenantDataEncryptionService'\nimport { isTenantDataEncryptionEnabled } from './toggles'\nimport { isEncryptionDebugEnabled } from './toggles'\nimport { resolveTenantEncryptionService } from './customFieldValues'\n\ntype Scoped = {\n tenantId?: string | null\n tenant_id?: string | null\n tenant?: { id?: string | null } | null\n organizationId?: string | null\n organization_id?: string | null\n organization?: { id?: string | null } | null\n}\n\ntype Scope = { tenantId: string | null; organizationId: string | null }\n\nfunction resolveScope(entity: Scoped): Scope {\n const tenantId = entity.tenantId ?? entity.tenant_id ?? entity.tenant?.id ?? null\n const organizationId = entity.organizationId ?? entity.organization_id ?? entity.organization?.id ?? null\n return {\n tenantId: tenantId ? String(tenantId) : null,\n organizationId: organizationId ? String(organizationId) : null,\n }\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n // eslint-disable-next-line no-console\n console.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nconst registeredEventManagers = new WeakSet<object>()\n\nconst subscribersByService = new WeakMap<TenantDataEncryptionService, TenantEncryptionSubscriber>()\n\nfunction getSubscriberForService(service: TenantDataEncryptionService): TenantEncryptionSubscriber {\n const existing = subscribersByService.get(service)\n if (existing) return existing\n const subscriber = new TenantEncryptionSubscriber(service)\n subscribersByService.set(service, subscriber)\n return subscriber\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nexport class TenantEncryptionSubscriber implements EventSubscriber<any> {\n constructor(private readonly service: TenantDataEncryptionService) {}\n\n getSubscribedEntities() {\n return [] // listen to all entities\n }\n\n private resolveMeta(\n meta: EntityMetadata<any> | undefined,\n entity: Record<string, unknown>,\n em?: { getMetadata?: () => any },\n ): EntityMetadata<any> | undefined {\n if (meta) return meta\n const ctor = (entity as any)?.constructor\n const name = ctor?.name\n const registry = em?.getMetadata?.()\n if (!registry || !name) return meta\n try { return registry.find?.(name) } catch {}\n try { return registry.find?.(ctor) } catch {}\n try { return registry.get?.(name) } catch {}\n try { return registry.get?.(ctor) } catch {}\n const all =\n (typeof registry.getAll === 'function' && registry.getAll()) ||\n (Array.isArray((registry as any).metadata) ? (registry as any).metadata : undefined) ||\n (registry as any).metadata ||\n {}\n try {\n const entries = Array.isArray(all) ? all : Object.values<any>(all)\n const match = entries.find(\n (m: any) =>\n m?.className === name ||\n m?.name === name ||\n m?.entityName === name ||\n m?.collection === ctor?.prototype?.__meta?.tableName ||\n m?.tableName === ctor?.prototype?.__meta?.tableName,\n )\n if (match) return match as EntityMetadata<any>\n } catch {\n // best-effort\n }\n return meta\n }\n\n private resolveEntityId(meta: EntityMetadata<any> | undefined): string | null {\n try {\n return resolveEntityIdFromMetadata(meta)\n } catch {\n return null\n }\n }\n\n private syncOriginalEntityData(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getComparator?: () => any },\n ) {\n const helper = (target as any)?.__helper\n if (!helper || typeof helper !== 'object') return\n\n // Prefer MikroORM comparator snapshot so change detection uses the expected shape.\n try {\n const comparator = em?.getComparator?.()\n if (comparator?.prepareEntity) {\n helper.__originalEntityData = comparator.prepareEntity(target)\n helper.__touched = false\n return\n }\n } catch (err) {\n debug('\u26AA\uFE0F subscriber.sync_original.comparator_failed', {\n entity: meta?.className || meta?.name,\n message: (err as Error)?.message ?? String(err),\n })\n }\n\n // Fallback: shallow snapshot of scalar/owner props to keep entities clean without comparator.\n const properties = meta?.properties ? Object.values(meta.properties) : []\n if (properties.length === 0) return\n const snapshot: Record<string, unknown> = { ...(helper.__originalEntityData ?? {}) }\n for (const prop of properties) {\n if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes((prop as any).kind)) continue\n const name = (prop as any).name\n if (typeof name !== 'string' || !name.length) continue\n snapshot[name] = (target as Record<string, unknown>)[name]\n }\n helper.__originalEntityData = snapshot\n helper.__touched = false\n }\n\n /**\n * Reports whether a managed entity currently carries un-flushed changes relative to its load\n * baseline, using MikroORM's own comparator so the verdict matches the change-set computer that\n * runs at flush time. Used to avoid re-baselining (and thereby discarding) pending writes when a\n * decrypt pass traverses back into an entity a command already mutated.\n */\n private hasPendingChanges(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getComparator?: () => any },\n ): boolean {\n const helper = (target as any)?.__helper\n if (!helper || typeof helper !== 'object') return false\n const original = helper.__originalEntityData\n if (!original) return false\n const entityName = meta?.className || meta?.name\n try {\n const comparator = em?.getComparator?.()\n if (entityName && comparator?.prepareEntity) {\n const current = comparator.prepareEntity(target)\n if (typeof comparator.matching === 'function') {\n return !comparator.matching(entityName, original, current)\n }\n if (typeof comparator.diffEntities === 'function') {\n const diff = comparator.diffEntities(entityName, original, current)\n return !!diff && Object.keys(diff).length > 0\n }\n }\n } catch (err) {\n debug('\u26AA\uFE0F subscriber.pending_changes.compare_failed', {\n entity: entityName,\n message: (err as Error)?.message ?? String(err),\n })\n }\n return helper.__touched === true\n }\n\n private async encrypt(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n changeSet?: { payload?: Record<string, unknown> },\n ) {\n if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })\n return\n }\n const resolvedMeta = this.resolveMeta(meta, target, em)\n const entityId = this.resolveEntityId(resolvedMeta)\n if (!entityId) {\n debug('\u26A0\uFE0F subscriber.decrypt.skip.entity_id_missing', {\n metaName: resolvedMeta?.className || resolvedMeta?.name,\n table: (resolvedMeta as any)?.tableName,\n })\n return\n }\n const { tenantId, organizationId } = resolveScope(target)\n if (!tenantId) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'no-tenant', entityId })\n return\n }\n const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId)\n const metaProps: Record<string, unknown> = resolvedMeta?.properties && typeof resolvedMeta.properties === 'object'\n ? resolvedMeta.properties\n : {}\n const payloadObj: Record<string, unknown> | null =\n changeSet && typeof changeSet === 'object'\n ? (typeof changeSet.payload === 'object' && changeSet.payload\n ? (changeSet.payload as Record<string, unknown>)\n : ((changeSet.payload = {}) as Record<string, unknown>))\n : null\n const updates: Record<string, unknown> = {}\n const columnNameFor = (propKey: string, prop: Record<string, unknown> | undefined): string => {\n try {\n if (prop && typeof prop === 'object') {\n const explicit = (prop as any)?.fieldName\n if (typeof explicit === 'string' && explicit.length) return explicit\n const name = (prop as any)?.name\n if (typeof name === 'string' && name.length) return name\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.column_name.resolve', {\n entityId,\n propKey,\n message: (err as Error)?.message ?? String(err),\n })\n }\n return toSnakeCase(propKey)\n }\n\n for (const [key, value] of Object.entries(encrypted)) {\n const prop = (metaProps as Record<string, any>)[key]\n if (!prop || typeof prop !== 'object') continue\n if ((target as Record<string, unknown>)[key] === value) continue\n updates[key] = value\n }\n if (Object.keys(updates).length === 0) return\n Object.assign(target, updates)\n if (payloadObj) {\n try {\n const ensureColumnKey = (propKey: string, value: unknown) => {\n const columnName = columnNameFor(propKey, (metaProps as Record<string, any>)[propKey])\n const canonicalKey = columnName || toSnakeCase(propKey)\n const aliases = new Set(\n [propKey, toSnakeCase(propKey), columnName, columnName ? toSnakeCase(columnName) : undefined].filter(\n (v): v is string => typeof v === 'string' && v.length > 0,\n ),\n )\n for (const alias of aliases) {\n if (Object.prototype.hasOwnProperty.call(payloadObj, alias)) delete payloadObj[alias]\n }\n const finalKey = columnName || toSnakeCase(propKey)\n payloadObj[finalKey] = value\n }\n for (const key of Object.keys(updates)) {\n ensureColumnKey(key, updates[key])\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.payload.normalize.error', {\n entityId,\n message: (err as Error)?.message ?? String(err),\n })\n }\n }\n }\n\n async decryptEntityGraph(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n opts: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},\n ) {\n await this.decrypt(target, meta, em, opts)\n }\n\n private async decrypt(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n {\n syncOriginal = false,\n seen,\n fallbackScope,\n }: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},\n ) {\n const visited = seen ?? new WeakSet<object>()\n if (visited.has(target as object)) return\n visited.add(target as object)\n if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })\n return\n }\n const resolvedMeta = this.resolveMeta(meta, target, em)\n const entityId = this.resolveEntityId(resolvedMeta)\n if (!entityId) return\n const { tenantId, organizationId } = resolveScope(target)\n const scopedTenantId = tenantId ?? fallbackScope?.tenantId ?? null\n const scopedOrgId = organizationId ?? fallbackScope?.organizationId ?? null\n if (!scopedTenantId) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'no-tenant', entityId })\n return\n }\n // Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a\n // managed entity that a command already mutated would clear its dirty changeset and silently\n // drop the pending write (e.g. an undo handler that mutates an entity, then loads a related\n // encrypted entity whose deep-decrypt recurses back into the still-dirty entity before flush).\n const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false\n const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId)\n Object.assign(target, decrypted)\n if (syncOriginal && !hadPendingChanges) {\n this.syncOriginalEntityData(target, resolvedMeta, em as any)\n }\n const nextFallback =\n fallbackScope ??\n (tenantId || organizationId\n ? { tenantId: tenantId ?? null, organizationId: organizationId ?? null }\n : { tenantId: scopedTenantId, organizationId: scopedOrgId })\n // Best-effort deep decrypt for loaded relations so populated graphs get cleaned too.\n try {\n const extractEntities = (value: any): any[] => {\n if (!value) return []\n // MikroORM Collection wrapper \u2014 MUST be checked before the Reference branch: both wrappers\n // expose isInitialized(), but only a Collection exposes getItems(). Matching the Reference\n // branch first returned the Collection wrapper itself instead of its items, so collection\n // relations were never deep-decrypted and leaked ciphertext (issue #2744).\n if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {\n try {\n return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []\n } catch {\n return []\n }\n }\n // MikroORM Reference wrapper\n if (typeof value === 'object' && typeof (value as any).isInitialized === 'function') {\n try {\n if ((value as any).isInitialized()) {\n const unwrapped = typeof (value as any).unwrap === 'function' ? (value as any).unwrap() : (value as any).__entity ?? (value as any)\n if (unwrapped && typeof unwrapped === 'object') return [unwrapped]\n }\n } catch {\n // ignore\n }\n return []\n }\n if (Array.isArray(value)) return value\n if (typeof value === 'object') return [value]\n return []\n }\n const props = resolvedMeta?.properties ? Object.values(resolvedMeta.properties) : []\n for (const prop of props) {\n const kind = (prop as any)?.kind\n const name = (prop as any)?.name\n if (typeof name !== 'string' || !name.length) continue\n const value = (target as any)[name]\n if (!value) continue\n // Single-valued relation\n if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(kind)) {\n const nestedEntities = extractEntities(value)\n for (const nested of nestedEntities) {\n const nestedMeta = this.resolveMeta((nested as any).__meta ?? (nested as any).__helper?.__meta, nested, em)\n await this.decrypt(nested as Record<string, unknown>, nestedMeta, em, {\n syncOriginal: true,\n seen: visited,\n fallbackScope: nextFallback,\n })\n }\n continue\n }\n // Collections\n if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(kind)) {\n const items = extractEntities(value)\n for (const item of items) {\n if (!item || typeof item !== 'object') continue\n const nestedMeta = this.resolveMeta((item as any).__meta ?? (item as any).__helper?.__meta, item, em)\n await this.decrypt(item as Record<string, unknown>, nestedMeta, em, {\n syncOriginal: true,\n seen: visited,\n fallbackScope: nextFallback,\n })\n }\n }\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.deep_decrypt.error', {\n entityId,\n message: (err as Error)?.message ?? String(err),\n })\n }\n }\n\n async beforeCreate(args: EventArgs<any>) {\n await this.encrypt(args.entity as Record<string, unknown>, args.meta, args.em, args.changeSet as any)\n }\n\n async beforeUpdate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em)\n await this.encrypt(args.entity as Record<string, unknown>, args.meta, args.em, args.changeSet as any)\n }\n\n async afterCreate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterUpdate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterUpsert(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async onLoad(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterFind(args: EventArgs<any> & { entities?: unknown[] }) {\n const entities = Array.isArray(args.entities) ? args.entities : []\n for (const entity of entities) {\n await this.decrypt(entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n }\n}\n\nexport function registerTenantEncryptionSubscriber(\n em: { getEventManager?: () => { registerSubscriber?: (subscriber: EventSubscriber<any>) => void } } | null | undefined,\n service: TenantDataEncryptionService,\n): void {\n const eventManager = em?.getEventManager?.()\n if (!eventManager || typeof eventManager.registerSubscriber !== 'function') return\n if (registeredEventManagers.has(eventManager)) return\n eventManager.registerSubscriber(new TenantEncryptionSubscriber(service))\n registeredEventManagers.add(eventManager)\n}\n\nexport async function decryptEntitiesWithFallbackScope(\n targets: unknown | unknown[],\n {\n em,\n tenantId,\n organizationId,\n encryptionService,\n }: {\n em: { getMetadata?: () => any; getComparator?: () => any }\n tenantId?: string | null\n organizationId?: string | null\n encryptionService?: TenantDataEncryptionService | null\n },\n): Promise<void> {\n if (!isTenantDataEncryptionEnabled()) return\n const list = Array.isArray(targets) ? targets : [targets]\n if (!list.length) return\n const service = encryptionService ?? resolveTenantEncryptionService(em as any)\n if (!service || !service.isEnabled()) return\n const subscriber = getSubscriberForService(service)\n const fallback: Scope | undefined =\n tenantId || organizationId\n ? {\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n }\n : undefined\n for (const entity of list) {\n if (!entity || typeof entity !== 'object') continue\n const meta = (entity as any).__meta ?? (entity as any).__helper?.__meta\n await subscriber.decryptEntityGraph(entity as Record<string, unknown>, meta, em as any, {\n syncOriginal: true,\n fallbackScope: fallback,\n })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityMetadata, EventArgs, EventSubscriber } from '@mikro-orm/core'\nimport { ReferenceKind } from '@mikro-orm/core'\nimport { resolveEntityIdFromMetadata } from './entityIds'\nimport { TenantDataEncryptionService, parseDecryptedFieldValue } from './tenantDataEncryptionService'\nimport { isTenantDataEncryptionEnabled } from './toggles'\nimport { isEncryptionDebugEnabled } from './toggles'\nimport { resolveTenantEncryptionService } from './customFieldValues'\n\ntype Scoped = {\n tenantId?: string | null\n tenant_id?: string | null\n tenant?: { id?: string | null } | null\n organizationId?: string | null\n organization_id?: string | null\n organization?: { id?: string | null } | null\n}\n\ntype Scope = { tenantId: string | null; organizationId: string | null }\n\nfunction resolveScope(entity: Scoped): Scope {\n const tenantId = entity.tenantId ?? entity.tenant_id ?? entity.tenant?.id ?? null\n const organizationId = entity.organizationId ?? entity.organization_id ?? entity.organization?.id ?? null\n return {\n tenantId: tenantId ? String(tenantId) : null,\n organizationId: organizationId ? String(organizationId) : null,\n }\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n // eslint-disable-next-line no-console\n console.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nfunction isJsonColumnProperty(prop: unknown): boolean {\n if (!prop || typeof prop !== 'object') return false\n const candidate = prop as Record<string, unknown>\n const type = typeof candidate.type === 'string' ? candidate.type.toLowerCase() : ''\n if (type === 'json' || type === 'jsonb') return true\n const columnTypes = Array.isArray(candidate.columnTypes) ? candidate.columnTypes : []\n if (columnTypes.some((value) => typeof value === 'string' && value.toLowerCase().includes('json'))) return true\n const customTypeName = (candidate.customType as { constructor?: { name?: string } } | undefined)?.constructor?.name\n return typeof customTypeName === 'string' && customTypeName.toLowerCase().includes('json')\n}\n\nconst registeredEventManagers = new WeakSet<object>()\n\nconst subscribersByService = new WeakMap<TenantDataEncryptionService, TenantEncryptionSubscriber>()\n\nfunction getSubscriberForService(service: TenantDataEncryptionService): TenantEncryptionSubscriber {\n const existing = subscribersByService.get(service)\n if (existing) return existing\n const subscriber = new TenantEncryptionSubscriber(service)\n subscribersByService.set(service, subscriber)\n return subscriber\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nexport class TenantEncryptionSubscriber implements EventSubscriber<any> {\n constructor(private readonly service: TenantDataEncryptionService) {}\n\n getSubscribedEntities() {\n return [] // listen to all entities\n }\n\n private resolveMeta(\n meta: EntityMetadata<any> | undefined,\n entity: Record<string, unknown>,\n em?: { getMetadata?: () => any },\n ): EntityMetadata<any> | undefined {\n if (meta) return meta\n const ctor = (entity as any)?.constructor\n const name = ctor?.name\n const registry = em?.getMetadata?.()\n if (!registry || !name) return meta\n try { return registry.find?.(name) } catch {}\n try { return registry.find?.(ctor) } catch {}\n try { return registry.get?.(name) } catch {}\n try { return registry.get?.(ctor) } catch {}\n const all =\n (typeof registry.getAll === 'function' && registry.getAll()) ||\n (Array.isArray((registry as any).metadata) ? (registry as any).metadata : undefined) ||\n (registry as any).metadata ||\n {}\n try {\n const entries = Array.isArray(all) ? all : Object.values<any>(all)\n const match = entries.find(\n (m: any) =>\n m?.className === name ||\n m?.name === name ||\n m?.entityName === name ||\n m?.collection === ctor?.prototype?.__meta?.tableName ||\n m?.tableName === ctor?.prototype?.__meta?.tableName,\n )\n if (match) return match as EntityMetadata<any>\n } catch {\n // best-effort\n }\n return meta\n }\n\n private resolveEntityId(meta: EntityMetadata<any> | undefined): string | null {\n try {\n return resolveEntityIdFromMetadata(meta)\n } catch {\n return null\n }\n }\n\n private restoreDecryptedJsonColumns(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n ) {\n const properties = meta?.properties\n if (!properties || typeof properties !== 'object') return\n for (const [propertyName, prop] of Object.entries(properties)) {\n if (!isJsonColumnProperty(prop)) continue\n const value = target[propertyName]\n if (typeof value !== 'string') continue\n const parsed = parseDecryptedFieldValue(value)\n if (parsed !== value) target[propertyName] = parsed\n }\n }\n\n private syncOriginalEntityData(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getComparator?: () => any },\n ) {\n const helper = (target as any)?.__helper\n if (!helper || typeof helper !== 'object') return\n\n // Prefer MikroORM comparator snapshot so change detection uses the expected shape.\n try {\n const comparator = em?.getComparator?.()\n if (comparator?.prepareEntity) {\n helper.__originalEntityData = comparator.prepareEntity(target)\n helper.__touched = false\n return\n }\n } catch (err) {\n debug('\u26AA\uFE0F subscriber.sync_original.comparator_failed', {\n entity: meta?.className || meta?.name,\n message: (err as Error)?.message ?? String(err),\n })\n }\n\n // Fallback: shallow snapshot of scalar/owner props to keep entities clean without comparator.\n const properties = meta?.properties ? Object.values(meta.properties) : []\n if (properties.length === 0) return\n const snapshot: Record<string, unknown> = { ...(helper.__originalEntityData ?? {}) }\n for (const prop of properties) {\n if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes((prop as any).kind)) continue\n const name = (prop as any).name\n if (typeof name !== 'string' || !name.length) continue\n snapshot[name] = (target as Record<string, unknown>)[name]\n }\n helper.__originalEntityData = snapshot\n helper.__touched = false\n }\n\n /**\n * Reports whether a managed entity currently carries un-flushed changes relative to its load\n * baseline, using MikroORM's own comparator so the verdict matches the change-set computer that\n * runs at flush time. Used to avoid re-baselining (and thereby discarding) pending writes when a\n * decrypt pass traverses back into an entity a command already mutated.\n */\n private hasPendingChanges(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getComparator?: () => any },\n ): boolean {\n const helper = (target as any)?.__helper\n if (!helper || typeof helper !== 'object') return false\n const original = helper.__originalEntityData\n if (!original) return false\n const entityName = meta?.className || meta?.name\n try {\n const comparator = em?.getComparator?.()\n if (entityName && comparator?.prepareEntity) {\n const current = comparator.prepareEntity(target)\n if (typeof comparator.matching === 'function') {\n return !comparator.matching(entityName, original, current)\n }\n if (typeof comparator.diffEntities === 'function') {\n const diff = comparator.diffEntities(entityName, original, current)\n return !!diff && Object.keys(diff).length > 0\n }\n }\n } catch (err) {\n debug('\u26AA\uFE0F subscriber.pending_changes.compare_failed', {\n entity: entityName,\n message: (err as Error)?.message ?? String(err),\n })\n }\n return helper.__touched === true\n }\n\n private async encrypt(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n changeSet?: { payload?: Record<string, unknown> },\n ) {\n if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })\n return\n }\n const resolvedMeta = this.resolveMeta(meta, target, em)\n const entityId = this.resolveEntityId(resolvedMeta)\n if (!entityId) {\n debug('\u26A0\uFE0F subscriber.decrypt.skip.entity_id_missing', {\n metaName: resolvedMeta?.className || resolvedMeta?.name,\n table: (resolvedMeta as any)?.tableName,\n })\n return\n }\n const { tenantId, organizationId } = resolveScope(target)\n if (!tenantId) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'no-tenant', entityId })\n return\n }\n const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId)\n const metaProps: Record<string, unknown> = resolvedMeta?.properties && typeof resolvedMeta.properties === 'object'\n ? resolvedMeta.properties\n : {}\n const payloadObj: Record<string, unknown> | null =\n changeSet && typeof changeSet === 'object'\n ? (typeof changeSet.payload === 'object' && changeSet.payload\n ? (changeSet.payload as Record<string, unknown>)\n : ((changeSet.payload = {}) as Record<string, unknown>))\n : null\n const updates: Record<string, unknown> = {}\n const columnNameFor = (propKey: string, prop: Record<string, unknown> | undefined): string => {\n try {\n if (prop && typeof prop === 'object') {\n const explicit = (prop as any)?.fieldName\n if (typeof explicit === 'string' && explicit.length) return explicit\n const name = (prop as any)?.name\n if (typeof name === 'string' && name.length) return name\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.column_name.resolve', {\n entityId,\n propKey,\n message: (err as Error)?.message ?? String(err),\n })\n }\n return toSnakeCase(propKey)\n }\n\n for (const [key, value] of Object.entries(encrypted)) {\n const prop = (metaProps as Record<string, any>)[key]\n if (!prop || typeof prop !== 'object') continue\n if ((target as Record<string, unknown>)[key] === value) continue\n updates[key] = value\n }\n if (Object.keys(updates).length === 0) return\n Object.assign(target, updates)\n if (payloadObj) {\n try {\n const ensureColumnKey = (propKey: string, value: unknown) => {\n const columnName = columnNameFor(propKey, (metaProps as Record<string, any>)[propKey])\n const canonicalKey = columnName || toSnakeCase(propKey)\n const aliases = new Set(\n [propKey, toSnakeCase(propKey), columnName, columnName ? toSnakeCase(columnName) : undefined].filter(\n (v): v is string => typeof v === 'string' && v.length > 0,\n ),\n )\n for (const alias of aliases) {\n if (Object.prototype.hasOwnProperty.call(payloadObj, alias)) delete payloadObj[alias]\n }\n const finalKey = columnName || toSnakeCase(propKey)\n payloadObj[finalKey] = value\n }\n for (const key of Object.keys(updates)) {\n ensureColumnKey(key, updates[key])\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.payload.normalize.error', {\n entityId,\n message: (err as Error)?.message ?? String(err),\n })\n }\n }\n }\n\n async decryptEntityGraph(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n opts: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},\n ) {\n await this.decrypt(target, meta, em, opts)\n }\n\n private async decrypt(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n {\n syncOriginal = false,\n seen,\n fallbackScope,\n }: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},\n ) {\n const visited = seen ?? new WeakSet<object>()\n if (visited.has(target as object)) return\n visited.add(target as object)\n if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })\n return\n }\n const resolvedMeta = this.resolveMeta(meta, target, em)\n const entityId = this.resolveEntityId(resolvedMeta)\n if (!entityId) return\n const { tenantId, organizationId } = resolveScope(target)\n const scopedTenantId = tenantId ?? fallbackScope?.tenantId ?? null\n const scopedOrgId = organizationId ?? fallbackScope?.organizationId ?? null\n if (!scopedTenantId) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'no-tenant', entityId })\n return\n }\n // Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a\n // managed entity that a command already mutated would clear its dirty changeset and silently\n // drop the pending write (e.g. an undo handler that mutates an entity, then loads a related\n // encrypted entity whose deep-decrypt recurses back into the still-dirty entity before flush).\n const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false\n const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId)\n Object.assign(target, decrypted)\n this.restoreDecryptedJsonColumns(target, resolvedMeta)\n if (syncOriginal && !hadPendingChanges) {\n this.syncOriginalEntityData(target, resolvedMeta, em as any)\n }\n const nextFallback =\n fallbackScope ??\n (tenantId || organizationId\n ? { tenantId: tenantId ?? null, organizationId: organizationId ?? null }\n : { tenantId: scopedTenantId, organizationId: scopedOrgId })\n // Best-effort deep decrypt for loaded relations so populated graphs get cleaned too.\n try {\n const extractEntities = (value: any): any[] => {\n if (!value) return []\n // MikroORM Collection wrapper \u2014 MUST be checked before the Reference branch: both wrappers\n // expose isInitialized(), but only a Collection exposes getItems(). Matching the Reference\n // branch first returned the Collection wrapper itself instead of its items, so collection\n // relations were never deep-decrypted and leaked ciphertext (issue #2744).\n if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {\n try {\n return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []\n } catch {\n return []\n }\n }\n // MikroORM Reference wrapper\n if (typeof value === 'object' && typeof (value as any).isInitialized === 'function') {\n try {\n if ((value as any).isInitialized()) {\n const unwrapped = typeof (value as any).unwrap === 'function' ? (value as any).unwrap() : (value as any).__entity ?? (value as any)\n if (unwrapped && typeof unwrapped === 'object') return [unwrapped]\n }\n } catch {\n // ignore\n }\n return []\n }\n if (Array.isArray(value)) return value\n if (typeof value === 'object') return [value]\n return []\n }\n const props = resolvedMeta?.properties ? Object.values(resolvedMeta.properties) : []\n for (const prop of props) {\n const kind = (prop as any)?.kind\n const name = (prop as any)?.name\n if (typeof name !== 'string' || !name.length) continue\n const value = (target as any)[name]\n if (!value) continue\n // Single-valued relation\n if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(kind)) {\n const nestedEntities = extractEntities(value)\n for (const nested of nestedEntities) {\n const nestedMeta = this.resolveMeta((nested as any).__meta ?? (nested as any).__helper?.__meta, nested, em)\n await this.decrypt(nested as Record<string, unknown>, nestedMeta, em, {\n syncOriginal: true,\n seen: visited,\n fallbackScope: nextFallback,\n })\n }\n continue\n }\n // Collections\n if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(kind)) {\n const items = extractEntities(value)\n for (const item of items) {\n if (!item || typeof item !== 'object') continue\n const nestedMeta = this.resolveMeta((item as any).__meta ?? (item as any).__helper?.__meta, item, em)\n await this.decrypt(item as Record<string, unknown>, nestedMeta, em, {\n syncOriginal: true,\n seen: visited,\n fallbackScope: nextFallback,\n })\n }\n }\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.deep_decrypt.error', {\n entityId,\n message: (err as Error)?.message ?? String(err),\n })\n }\n }\n\n async beforeCreate(args: EventArgs<any>) {\n await this.encrypt(args.entity as Record<string, unknown>, args.meta, args.em, args.changeSet as any)\n }\n\n async beforeUpdate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em)\n await this.encrypt(args.entity as Record<string, unknown>, args.meta, args.em, args.changeSet as any)\n }\n\n async afterCreate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterUpdate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterUpsert(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async onLoad(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterFind(args: EventArgs<any> & { entities?: unknown[] }) {\n const entities = Array.isArray(args.entities) ? args.entities : []\n for (const entity of entities) {\n await this.decrypt(entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n }\n}\n\nexport function registerTenantEncryptionSubscriber(\n em: { getEventManager?: () => { registerSubscriber?: (subscriber: EventSubscriber<any>) => void } } | null | undefined,\n service: TenantDataEncryptionService,\n): void {\n const eventManager = em?.getEventManager?.()\n if (!eventManager || typeof eventManager.registerSubscriber !== 'function') return\n if (registeredEventManagers.has(eventManager)) return\n eventManager.registerSubscriber(new TenantEncryptionSubscriber(service))\n registeredEventManagers.add(eventManager)\n}\n\nexport async function decryptEntitiesWithFallbackScope(\n targets: unknown | unknown[],\n {\n em,\n tenantId,\n organizationId,\n encryptionService,\n }: {\n em: { getMetadata?: () => any; getComparator?: () => any }\n tenantId?: string | null\n organizationId?: string | null\n encryptionService?: TenantDataEncryptionService | null\n },\n): Promise<void> {\n if (!isTenantDataEncryptionEnabled()) return\n const list = Array.isArray(targets) ? targets : [targets]\n if (!list.length) return\n const service = encryptionService ?? resolveTenantEncryptionService(em as any)\n if (!service || !service.isEnabled()) return\n const subscriber = getSubscriberForService(service)\n const fallback: Scope | undefined =\n tenantId || organizationId\n ? {\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n }\n : undefined\n for (const entity of list) {\n if (!entity || typeof entity !== 'object') continue\n const meta = (entity as any).__meta ?? (entity as any).__helper?.__meta\n await subscriber.decryptEntityGraph(entity as Record<string, unknown>, meta, em as any, {\n syncOriginal: true,\n fallbackScope: fallback,\n })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -26,7 +26,10 @@ async function checkRateLimit(rateLimiterService, config, key, errorMessage) {
26
26
  }
27
27
  function getClientIp(req, trustProxyDepth = 0) {
28
28
  const forwarded = req.headers.get("x-forwarded-for");
29
- if (forwarded && trustProxyDepth > 0) {
29
+ if (trustProxyDepth <= 0) {
30
+ return null;
31
+ }
32
+ if (forwarded) {
30
33
  const ips = forwarded.split(",").map((ip) => ip.trim());
31
34
  const clientIndex = ips.length - trustProxyDepth;
32
35
  return clientIndex >= 0 ? ips[clientIndex] : ips[0];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ratelimit/helpers.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust X-Forwarded-For; fall back to X-Real-IP or null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP.\n *\n * With depth=0, X-Forwarded-For is ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (forwarded && trustProxyDepth > 0) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] : ips[0]\n }\n return req.headers.get('x-real-ip') ?? null\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAElC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAMD,eAAsB,eACpB,oBACA,QACA,KACA,cAC8B;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAYO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,aAAa,kBAAkB,GAAG;AACpC,UAAM,MAAM,UAAU,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;AACtD,UAAM,cAAc,IAAI,SAAS;AACjC,WAAO,eAAe,IAAI,IAAI,WAAW,IAAI,IAAI,CAAC;AAAA,EACpD;AACA,SAAO,IAAI,QAAQ,IAAI,WAAW,KAAK;AACzC;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust proxy-provided IP headers; return null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP.\n *\n * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (trustProxyDepth <= 0) {\n return null\n }\n\n if (forwarded) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] : ips[0]\n }\n return req.headers.get('x-real-ip') ?? null\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAElC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAMD,eAAsB,eACpB,oBACA,QACA,KACA,cAC8B;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAYO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,mBAAmB,GAAG;AACxB,WAAO;AAAA,EACT;AAEA,MAAI,WAAW;AACb,UAAM,MAAM,UAAU,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;AACtD,UAAM,cAAc,IAAI,SAAS;AACjC,WAAO,eAAe,IAAI,IAAI,WAAW,IAAI,IAAI,CAAC;AAAA,EACpD;AACA,SAAO,IAAI,QAAQ,IAAI,WAAW,KAAK;AACzC;",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.6-develop.6429.1.2fba7258d1";
1
+ const APP_VERSION = "0.6.6-develop.6450.1.b493fb430c";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6429.1.2fba7258d1'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6450.1.b493fb430c'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.6-develop.6429.1.2fba7258d1",
3
+ "version": "0.6.6-develop.6450.1.b493fb430c",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -93,7 +93,7 @@
93
93
  "@mikro-orm/core": "^7.1.5",
94
94
  "@mikro-orm/decorators": "^7.1.5",
95
95
  "@mikro-orm/postgresql": "^7.1.5",
96
- "@open-mercato/cache": "0.6.6-develop.6429.1.2fba7258d1",
96
+ "@open-mercato/cache": "0.6.6-develop.6450.1.b493fb430c",
97
97
  "@types/sanitize-html": "^2.16.1",
98
98
  "dotenv": "^17.4.2",
99
99
  "rate-limiter-flexible": "^11.2.0",
@@ -1,4 +1,4 @@
1
- import { ensureOrganizationScope } from '@open-mercato/shared/lib/commands/scope'
1
+ import { ensureOrganizationScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
2
2
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
3
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
4
 
@@ -6,21 +6,24 @@ type ScopeShape = NonNullable<CommandRuntimeContext['organizationScope']>
6
6
 
7
7
  function buildCtx(overrides: {
8
8
  isSuperAdmin?: boolean
9
+ tenantId?: string | null
9
10
  orgId?: string | null
10
11
  selectedOrganizationId?: string | null
11
12
  organizationScope?: ScopeShape | null
13
+ systemActor?: boolean
12
14
  }): CommandRuntimeContext {
13
15
  return {
14
16
  container: {} as CommandRuntimeContext['container'],
15
17
  auth: {
16
18
  sub: 'user-1',
17
- tenantId: 'tenant-1',
19
+ tenantId: overrides.tenantId === undefined ? 'tenant-1' : overrides.tenantId,
18
20
  orgId: overrides.orgId ?? null,
19
21
  isSuperAdmin: overrides.isSuperAdmin ?? false,
20
22
  },
21
23
  organizationScope: overrides.organizationScope ?? null,
22
24
  selectedOrganizationId: overrides.selectedOrganizationId ?? null,
23
25
  organizationIds: null,
26
+ systemActor: overrides.systemActor,
24
27
  }
25
28
  }
26
29
 
@@ -34,6 +37,33 @@ function buildScope(overrides: Partial<ScopeShape>): ScopeShape {
34
37
  }
35
38
  }
36
39
 
40
+ describe('ensureTenantScope', () => {
41
+ it('denies a tenant-less non-superadmin acting on a tenant-scoped target (#3910)', () => {
42
+ const ctx = buildCtx({ tenantId: null, isSuperAdmin: false })
43
+ expect(() => ensureTenantScope(ctx, 'tenant-2')).toThrow(CrudHttpError)
44
+ })
45
+
46
+ it('allows super admins with no tenant context to act on tenant-scoped targets', () => {
47
+ const ctx = buildCtx({ tenantId: null, isSuperAdmin: true })
48
+ expect(() => ensureTenantScope(ctx, 'tenant-2')).not.toThrow()
49
+ })
50
+
51
+ it('preserves legacy system contexts without an authenticated actor', () => {
52
+ const ctx = { ...buildCtx({}), auth: null }
53
+ expect(() => ensureTenantScope(ctx, 'tenant-2')).not.toThrow()
54
+ })
55
+
56
+ it('denies authenticated tenant-less systemActor contexts unless they are superadmin (#3910)', () => {
57
+ const ctx = buildCtx({ tenantId: null, isSuperAdmin: false, systemActor: true })
58
+ expect(() => ensureTenantScope(ctx, 'tenant-2')).toThrow(CrudHttpError)
59
+ })
60
+
61
+ it('allows a scoped principal acting inside its own tenant', () => {
62
+ const ctx = buildCtx({ tenantId: 'tenant-1' })
63
+ expect(() => ensureTenantScope(ctx, 'tenant-1')).not.toThrow()
64
+ })
65
+ })
66
+
37
67
  describe('ensureOrganizationScope', () => {
38
68
  it('denies a restricted floating user acting on an org outside allowedIds (#2239)', () => {
39
69
  const ctx = buildCtx({
@@ -59,6 +89,43 @@ describe('ensureOrganizationScope', () => {
59
89
  expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
60
90
  })
61
91
 
92
+ it('denies null-tenant organization scope for a non-superadmin (#3910)', () => {
93
+ const ctx = buildCtx({
94
+ tenantId: null,
95
+ organizationScope: buildScope({ tenantId: null, allowedIds: null, filterIds: null }),
96
+ })
97
+ expect(() => ensureOrganizationScope(ctx, 'org-b')).toThrow(CrudHttpError)
98
+ })
99
+
100
+ it('allows null-tenant organization scope for a superadmin', () => {
101
+ const ctx = buildCtx({
102
+ tenantId: null,
103
+ isSuperAdmin: true,
104
+ organizationScope: buildScope({ tenantId: null, allowedIds: null, filterIds: null }),
105
+ })
106
+ expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
107
+ })
108
+
109
+ it('preserves null-tenant organization scope for system contexts without an authenticated actor', () => {
110
+ const ctx = {
111
+ ...buildCtx({
112
+ tenantId: null,
113
+ organizationScope: buildScope({ tenantId: null, allowedIds: null, filterIds: null }),
114
+ }),
115
+ auth: null,
116
+ }
117
+ expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
118
+ })
119
+
120
+ it('denies authenticated null-tenant systemActor organization scopes unless they are superadmin (#3910)', () => {
121
+ const ctx = buildCtx({
122
+ tenantId: null,
123
+ systemActor: true,
124
+ organizationScope: buildScope({ tenantId: null, allowedIds: null, filterIds: null }),
125
+ })
126
+ expect(() => ensureOrganizationScope(ctx, 'org-b')).toThrow(CrudHttpError)
127
+ })
128
+
62
129
  it('allows a restricted user acting on an org inside allowedIds (allow-path regression)', () => {
63
130
  const ctx = buildCtx({
64
131
  orgId: 'org-a',
@@ -122,6 +122,12 @@ export function ensureOrganizationScope(ctx: CommandRuntimeContext, organization
122
122
  return
123
123
  }
124
124
 
125
+ if (!scope.tenantId && organizationId && ctx.auth && !isSuperAdmin) {
126
+ const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
127
+ logScopeViolation(ctx, organizationId, currentOrg)
128
+ throw new CrudHttpError(403, { error: 'Forbidden' })
129
+ }
130
+
125
131
  if (
126
132
  isOrganizationAccessAllowed({
127
133
  isSuperAdmin,
@@ -138,8 +144,16 @@ export function ensureOrganizationScope(ctx: CommandRuntimeContext, organization
138
144
  }
139
145
 
140
146
  export function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {
147
+ const isSuperAdmin = ctx.auth?.isSuperAdmin === true
141
148
  const currentTenant = ctx.auth?.tenantId ?? null
142
- if (currentTenant && currentTenant !== tenantId) {
149
+ if (!currentTenant) {
150
+ if (tenantId && ctx.auth && !isSuperAdmin) {
151
+ logTenantScopeViolation(ctx, tenantId, currentTenant)
152
+ throw new CrudHttpError(403, { error: 'Forbidden' })
153
+ }
154
+ return
155
+ }
156
+ if (currentTenant !== tenantId) {
143
157
  logTenantScopeViolation(ctx, tenantId, currentTenant)
144
158
  throw new CrudHttpError(403, { error: 'Forbidden' })
145
159
  }
@@ -0,0 +1,31 @@
1
+ import { serializeExport } from '../exporters'
2
+
3
+ describe('CRUD export serializers', () => {
4
+ it('neutralizes spreadsheet formula prefixes in CSV string cells', () => {
5
+ const serialized = serializeExport({
6
+ columns: [
7
+ { field: 'name', header: 'Name' },
8
+ { field: 'note', header: 'Note' },
9
+ { field: 'balance', header: 'Balance' },
10
+ ],
11
+ rows: [
12
+ {
13
+ name: "=cmd|'/c calc'!A1",
14
+ note: '@SUM(A1:A2)',
15
+ balance: -42,
16
+ },
17
+ {
18
+ name: '\t=HYPERLINK("https://example.invalid")',
19
+ note: '\r+1+1',
20
+ balance: '-7',
21
+ },
22
+ ],
23
+ }, 'csv')
24
+
25
+ expect(serialized.body.split('\n')).toEqual([
26
+ 'Name,Note,Balance',
27
+ "'=cmd|'/c calc'!A1,'@SUM(A1:A2),-42",
28
+ '"\'\t=HYPERLINK(""https://example.invalid"")","\'\r+1+1",\'-7',
29
+ ])
30
+ })
31
+ })
@@ -39,6 +39,21 @@ function escapeCsv(value: string): string {
39
39
  return value
40
40
  }
41
41
 
42
+ function neutralizeSpreadsheetFormula(value: string): string {
43
+ if (/^[=+\-@\t\r\n]/.test(value)) {
44
+ return `'${value}`
45
+ }
46
+ return value
47
+ }
48
+
49
+ function normalizeCsvValue(value: unknown): string {
50
+ const normalized = normalizeValue(value)
51
+ if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {
52
+ return normalized
53
+ }
54
+ return neutralizeSpreadsheetFormula(normalized)
55
+ }
56
+
42
57
  function escapeMarkdown(value: string): string {
43
58
  const escaped = value
44
59
  .replace(/\\/g, '\\\\')
@@ -70,7 +85,7 @@ function serializeCsv(prepared: PreparedExport): SerializedExport {
70
85
  const lines = [headers.join(',')]
71
86
  for (const row of prepared.rows) {
72
87
  const values = prepared.columns.map((col) => {
73
- const raw = normalizeValue(row[col.field])
88
+ const raw = normalizeCsvValue(row[col.field])
74
89
  return escapeCsv(raw)
75
90
  })
76
91
  lines.push(values.join(','))
@@ -0,0 +1,95 @@
1
+ import { TenantEncryptionSubscriber } from '../subscriber'
2
+ import { registerEntityIds } from '../entityIds'
3
+ import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
4
+
5
+ // Regression coverage for issue #3672: an encrypted column declared as `type: 'json'`
6
+ // (e.g. inbox_ops:inbox_proposal_action.payload) round-trips through encryption as a
7
+ // JSON *string* — `decryptFields` intentionally never re-parses decrypted entity columns
8
+ // so that string/text columns whose contents look like JSON stay raw. The entity-hydration
9
+ // subscriber must restore json-typed columns back to objects/arrays, otherwise consumers
10
+ // (the proposal Edit dialog) read `payload.orderNumber` off a string and render blank fields.
11
+
12
+ function makeEm() {
13
+ return { getComparator: () => undefined, getMetadata: () => undefined }
14
+ }
15
+
16
+ const META = {
17
+ className: 'Thing',
18
+ tableName: 'things',
19
+ properties: {
20
+ payload: { name: 'payload', type: 'json' },
21
+ participants: { name: 'participants', type: 'json' },
22
+ description: { name: 'description', type: 'text' },
23
+ },
24
+ } as any
25
+
26
+ describe('TenantEncryptionSubscriber json-column restoration (issue #3672)', () => {
27
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
28
+
29
+ beforeEach(() => {
30
+ delete process.env.TENANT_DATA_ENCRYPTION // default => encryption enabled
31
+ registerEntityIds({ test: { thing: 'test:thing' } })
32
+ })
33
+
34
+ afterEach(() => {
35
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
36
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
37
+ jest.restoreAllMocks()
38
+ })
39
+
40
+ function makeService(
41
+ decryptEntityPayload: (entityId: string, target: Record<string, unknown>) => Record<string, unknown>,
42
+ ): TenantDataEncryptionService {
43
+ return {
44
+ isEnabled: () => true,
45
+ async decryptEntityPayload(entityId: string, target: Record<string, unknown>) {
46
+ return decryptEntityPayload(entityId, target)
47
+ },
48
+ } as unknown as TenantDataEncryptionService
49
+ }
50
+
51
+ it('parses decrypted json object columns back into objects', async () => {
52
+ const entity: Record<string, unknown> = { tenantId: 't1', payload: 'enc:cipher' }
53
+ const subscriber = new TenantEncryptionSubscriber(
54
+ makeService(() => ({ payload: '{"orderId":"o-1","orderNumber":"SO-100","noteAdditions":["hi"]}' })),
55
+ )
56
+
57
+ await subscriber.decryptEntityGraph(entity, META, makeEm(), { syncOriginal: true })
58
+
59
+ expect(entity.payload).toEqual({ orderId: 'o-1', orderNumber: 'SO-100', noteAdditions: ['hi'] })
60
+ })
61
+
62
+ it('parses decrypted json array columns back into arrays', async () => {
63
+ const entity: Record<string, unknown> = { tenantId: 't1', participants: 'enc:cipher' }
64
+ const subscriber = new TenantEncryptionSubscriber(
65
+ makeService(() => ({ participants: '[{"name":"Ada"},{"name":"Bo"}]' })),
66
+ )
67
+
68
+ await subscriber.decryptEntityGraph(entity, META, makeEm(), { syncOriginal: true })
69
+
70
+ expect(entity.participants).toEqual([{ name: 'Ada' }, { name: 'Bo' }])
71
+ })
72
+
73
+ it('leaves decrypted string/text columns untouched even when they look like JSON', async () => {
74
+ const entity: Record<string, unknown> = { tenantId: 't1', description: 'enc:cipher' }
75
+ const subscriber = new TenantEncryptionSubscriber(
76
+ makeService(() => ({ description: '{"this":"is a display name, not json"}' })),
77
+ )
78
+
79
+ await subscriber.decryptEntityGraph(entity, META, makeEm(), { syncOriginal: true })
80
+
81
+ expect(entity.description).toBe('{"this":"is a display name, not json"}')
82
+ })
83
+
84
+ it('leaves non-string json column values untouched', async () => {
85
+ const alreadyObject = { orderNumber: 'SO-200' }
86
+ const entity: Record<string, unknown> = { tenantId: 't1', payload: alreadyObject }
87
+ const subscriber = new TenantEncryptionSubscriber(
88
+ makeService((_id, target) => ({ payload: target.payload })),
89
+ )
90
+
91
+ await subscriber.decryptEntityGraph(entity, META, makeEm(), { syncOriginal: true })
92
+
93
+ expect(entity.payload).toBe(alreadyObject)
94
+ })
95
+ })
@@ -1,7 +1,7 @@
1
1
  import type { EntityMetadata, EventArgs, EventSubscriber } from '@mikro-orm/core'
2
2
  import { ReferenceKind } from '@mikro-orm/core'
3
3
  import { resolveEntityIdFromMetadata } from './entityIds'
4
- import { TenantDataEncryptionService } from './tenantDataEncryptionService'
4
+ import { TenantDataEncryptionService, parseDecryptedFieldValue } from './tenantDataEncryptionService'
5
5
  import { isTenantDataEncryptionEnabled } from './toggles'
6
6
  import { isEncryptionDebugEnabled } from './toggles'
7
7
  import { resolveTenantEncryptionService } from './customFieldValues'
@@ -36,6 +36,17 @@ function debug(event: string, payload: Record<string, unknown>) {
36
36
  }
37
37
  }
38
38
 
39
+ function isJsonColumnProperty(prop: unknown): boolean {
40
+ if (!prop || typeof prop !== 'object') return false
41
+ const candidate = prop as Record<string, unknown>
42
+ const type = typeof candidate.type === 'string' ? candidate.type.toLowerCase() : ''
43
+ if (type === 'json' || type === 'jsonb') return true
44
+ const columnTypes = Array.isArray(candidate.columnTypes) ? candidate.columnTypes : []
45
+ if (columnTypes.some((value) => typeof value === 'string' && value.toLowerCase().includes('json'))) return true
46
+ const customTypeName = (candidate.customType as { constructor?: { name?: string } } | undefined)?.constructor?.name
47
+ return typeof customTypeName === 'string' && customTypeName.toLowerCase().includes('json')
48
+ }
49
+
39
50
  const registeredEventManagers = new WeakSet<object>()
40
51
 
41
52
  const subscribersByService = new WeakMap<TenantDataEncryptionService, TenantEncryptionSubscriber>()
@@ -102,6 +113,21 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
102
113
  }
103
114
  }
104
115
 
116
+ private restoreDecryptedJsonColumns(
117
+ target: Record<string, unknown>,
118
+ meta: EntityMetadata<any> | undefined,
119
+ ) {
120
+ const properties = meta?.properties
121
+ if (!properties || typeof properties !== 'object') return
122
+ for (const [propertyName, prop] of Object.entries(properties)) {
123
+ if (!isJsonColumnProperty(prop)) continue
124
+ const value = target[propertyName]
125
+ if (typeof value !== 'string') continue
126
+ const parsed = parseDecryptedFieldValue(value)
127
+ if (parsed !== value) target[propertyName] = parsed
128
+ }
129
+ }
130
+
105
131
  private syncOriginalEntityData(
106
132
  target: Record<string, unknown>,
107
133
  meta: EntityMetadata<any> | undefined,
@@ -308,6 +334,7 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
308
334
  const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false
309
335
  const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId)
310
336
  Object.assign(target, decrypted)
337
+ this.restoreDecryptedJsonColumns(target, resolvedMeta)
311
338
  if (syncOriginal && !hadPendingChanges) {
312
339
  this.syncOriginalEntityData(target, resolvedMeta, em as any)
313
340
  }
@@ -101,14 +101,14 @@ describe('getClientIp', () => {
101
101
  expect(getClientIp(req)).toBeNull()
102
102
  })
103
103
 
104
- it('falls back to x-real-ip when trustProxyDepth is 0', () => {
104
+ it('ignores x-real-ip when trustProxyDepth is 0', () => {
105
105
  const req = new Request('http://localhost', {
106
106
  headers: {
107
107
  'x-forwarded-for': 'spoofed-ip',
108
108
  'x-real-ip': '172.16.0.5',
109
109
  },
110
110
  })
111
- expect(getClientIp(req, 0)).toBe('172.16.0.5')
111
+ expect(getClientIp(req, 0)).toBeNull()
112
112
  })
113
113
 
114
114
  it('extracts last IP with trustProxyDepth=1 (single proxy)', () => {
@@ -45,15 +45,19 @@ export async function checkRateLimit(
45
45
  * Extract client IP from a request, respecting reverse proxy trust depth.
46
46
  *
47
47
  * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.
48
- * - 0 (default): Do not trust X-Forwarded-For; fall back to X-Real-IP or null.
48
+ * - 0 (default): Do not trust proxy-provided IP headers; return null.
49
49
  * - 1: One trusted proxy (e.g. nginx) — the last entry in X-Forwarded-For is the client IP.
50
50
  * - N: N trusted proxies — the Nth-from-last entry is the client IP.
51
51
  *
52
- * With depth=0, X-Forwarded-For is ignored entirely to prevent spoofing.
52
+ * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.
53
53
  */
54
54
  export function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {
55
55
  const forwarded = req.headers.get('x-forwarded-for')
56
- if (forwarded && trustProxyDepth > 0) {
56
+ if (trustProxyDepth <= 0) {
57
+ return null
58
+ }
59
+
60
+ if (forwarded) {
57
61
  const ips = forwarded.split(',').map((ip) => ip.trim())
58
62
  const clientIndex = ips.length - trustProxyDepth
59
63
  return clientIndex >= 0 ? ips[clientIndex] : ips[0]