@open-mercato/shared 0.6.6-develop.6431.1.g1037269810 → 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": "AAAA,SAAS,qBAAqB;AAE9B,SAAS,mCAAmC;AAC5C,SAAS,+BAA+B;AACxC,SAAS,WAAW;AAEpB,SAAS,qBAAqB,KAA4B;AACxD,QAAM,cACJ,IAAI,WAAW,OAAO,IAAI,YAAY,WAClC;AAAA,IACE,QAAS,IAAI,QAAoB,UAAU;AAAA,IAC3C,KAAM,IAAI,QAAoB,OAAO;AAAA,EACvC,IACA;AACN,QAAM,QAAQ,IAAI,oBACd;AAAA,IACE,YAAY,IAAI,kBAAkB,cAAc;AAAA,IAChD,UAAU,IAAI,kBAAkB,YAAY;AAAA,IAC5C,iBAAiB,MAAM,QAAQ,IAAI,kBAAkB,UAAU,IAC3D,IAAI,kBAAkB,WAAW,SACjC;AAAA,IACJ,gBAAgB,MAAM,QAAQ,IAAI,kBAAkB,SAAS,IACzD,IAAI,kBAAkB,UAAU,SAChC;AAAA,EACN,IACA;AACJ,SAAO;AAAA,IACL,QAAQ,IAAI,MAAM,OAAO;AAAA,IACzB,eAAe,IAAI,MAAM,YAAY;AAAA,IACrC,qBAAqB,IAAI,MAAM,SAAS;AAAA,IACxC,wBAAwB,IAAI,0BAA0B;AAAA,IACtD,sBAAsB,MAAM,QAAQ,IAAI,eAAe,IAAI,IAAI,gBAAgB,SAAS;AAAA,IACxF;AAAA,IACA,SAAS;AAAA,EACX;AACF;AAEA,SAAS,oCAA6C;AACpD,SAAO,wBAAwB,IAAI,6BAA6B,KAAK;AACvE;AAEA,SAAS,kBACP,KACA,UACA,QACM;AACN,MAAI;AACF,QAAI,IAAI,aAAa,QAAQ;AAC3B,cAAQ,KAAK,0DAA0D;AAAA,QACrE,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,GAAG,qBAAqB,GAAG;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,8BAA8B,KAA4B,gBAA8B;AAC/F,MAAI;AACF,QAAI,IAAI,aAAa,QAAQ;AAC3B,cAAQ,KAAK,+EAA+E;AAAA,QAC1F,sBAAsB;AAAA,QACtB,mBAAmB,kCAAkC;AAAA,QACrD,GAAG,qBAAqB,GAAG;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,wBACP,KACA,kBACA,gBACM;AACN,MAAI;AACF,QAAI,IAAI,aAAa,QAAQ;AAC3B,cAAQ,KAAK,oDAAoD;AAAA,QAC/D;AAAA,QACA;AAAA,QACA,GAAG,qBAAqB,GAAG;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,wBAAwB,KAA4B,gBAA8B;AAChG,QAAM,eAAe,IAAI,MAAM,iBAAiB;AAChD,QAAM,QAAQ,IAAI;AAMlB,MAAI,CAAC,OAAO;AACV,QAAI,aAAc;AAClB,UAAMA,cAAa,IAAI,0BAA0B,IAAI,MAAM,SAAS;AACpE,QAAIA,aAAY;AACd,UAAIA,gBAAe,gBAAgB;AACjC,0BAAkB,KAAK,gBAAgBA,WAAU;AACjD,cAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,MACrD;AACA;AAAA,IACF;AAOA,QAAI,gBAAgB;AAClB,oCAA8B,KAAK,cAAc;AACjD,UAAI,kCAAkC,GAAG;AACvC,cAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,MACrD;AAAA,IACF;AACA;AAAA,EACF;AAEA,MAAI,CAAC,MAAM,YAAY,kBAAkB,IAAI,QAAQ,CAAC,cAAc;AAClE,UAAMA,cAAa,IAAI,0BAA0B,IAAI,MAAM,SAAS;AACpE,sBAAkB,KAAK,gBAAgBA,WAAU;AACjD,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,EACrD;AAEA,MACE,4BAA4B;AAAA,IAC1B;AAAA,IACA,wBAAwB,MAAM;AAAA,IAC9B,sBAAsB;AAAA,EACxB,CAAC,GACD;AACA;AAAA,EACF;AAEA,QAAM,aAAa,IAAI,0BAA0B,IAAI,MAAM,SAAS;AACpE,oBAAkB,KAAK,gBAAgB,UAAU;AACjD,QAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AACrD;AAEO,SAAS,kBAAkB,KAA4B,UAAwB;AACpF,QAAM,eAAe,IAAI,MAAM,iBAAiB;AAChD,QAAM,gBAAgB,IAAI,MAAM,YAAY;AAC5C,MAAI,CAAC,eAAe;AAClB,QAAI,YAAY,IAAI,QAAQ,CAAC,cAAc;AACzC,8BAAwB,KAAK,UAAU,aAAa;AACpD,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,IACrD;AACA;AAAA,EACF;AACA,MAAI,kBAAkB,UAAU;AAC9B,4BAAwB,KAAK,UAAU,aAAa;AACpD,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,EACrD;AACF;AAEO,SAAS,gBACd,QACA,gBACA,UACM;AACN,MAAI,OAAO,mBAAmB,kBAAkB,OAAO,aAAa,UAAU;AAC5E,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,kCAAkC,CAAC;AAAA,EAC3E;AACF;",
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
  }
@@ -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.6431.1.g1037269810";
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.6431.1.g1037269810'\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.6431.1.g1037269810",
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.6431.1.g1037269810",
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(','))
@@ -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]