@open-mercato/shared 0.6.6-develop.6386.1.391a1afb9e → 0.6.6-develop.6387.1.f4896ad565

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.
@@ -1,2 +1,2 @@
1
- [build:shared] found 228 entry points
1
+ [build:shared] found 229 entry points
2
2
  [build:shared] built successfully
@@ -0,0 +1,31 @@
1
+ const COORDINATE_RANGES = {
2
+ latitude: { min: -90, max: 90 },
3
+ longitude: { min: -180, max: 180 }
4
+ };
5
+ const DECIMAL_INPUT_PATTERN = /^[+-]?(\d+([.,]\d+)?|[.,]\d+)$/;
6
+ function parseCoordinate(trimmed) {
7
+ if (!DECIMAL_INPUT_PATTERN.test(trimmed)) return null;
8
+ const parsed = Number.parseFloat(trimmed.replace(",", "."));
9
+ return Number.isFinite(parsed) ? parsed : null;
10
+ }
11
+ function validateCoordinateInput(kind, value) {
12
+ const trimmed = typeof value === "string" ? value.trim() : "";
13
+ if (!trimmed.length) return { status: "empty" };
14
+ const parsed = parseCoordinate(trimmed);
15
+ if (parsed === null) return { status: "invalid" };
16
+ const { min, max } = COORDINATE_RANGES[kind];
17
+ if (parsed < min || parsed > max) return { status: "outOfRange", min, max };
18
+ return { status: "valid", value: parsed };
19
+ }
20
+ function normalizeCoordinateInput(value) {
21
+ const trimmed = typeof value === "string" ? value.trim() : "";
22
+ if (!trimmed.length) return void 0;
23
+ const parsed = parseCoordinate(trimmed);
24
+ return parsed === null ? void 0 : parsed;
25
+ }
26
+ export {
27
+ COORDINATE_RANGES,
28
+ normalizeCoordinateInput,
29
+ validateCoordinateInput
30
+ };
31
+ //# sourceMappingURL=coordinates.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/location/coordinates.ts"],
4
+ "sourcesContent": ["export const COORDINATE_RANGES = {\n latitude: { min: -90, max: 90 },\n longitude: { min: -180, max: 180 },\n} as const\n\nexport type CoordinateFieldKind = keyof typeof COORDINATE_RANGES\n\nexport type CoordinateValidationResult =\n | { status: 'empty' }\n | { status: 'invalid' }\n | { status: 'outOfRange'; min: number; max: number }\n | { status: 'valid'; value: number }\n\nconst DECIMAL_INPUT_PATTERN = /^[+-]?(\\d+([.,]\\d+)?|[.,]\\d+)$/\n\nfunction parseCoordinate(trimmed: string): number | null {\n if (!DECIMAL_INPUT_PATTERN.test(trimmed)) return null\n const parsed = Number.parseFloat(trimmed.replace(',', '.'))\n return Number.isFinite(parsed) ? parsed : null\n}\n\nexport function validateCoordinateInput(\n kind: CoordinateFieldKind,\n value: string | undefined,\n): CoordinateValidationResult {\n const trimmed = typeof value === 'string' ? value.trim() : ''\n if (!trimmed.length) return { status: 'empty' }\n const parsed = parseCoordinate(trimmed)\n if (parsed === null) return { status: 'invalid' }\n const { min, max } = COORDINATE_RANGES[kind]\n if (parsed < min || parsed > max) return { status: 'outOfRange', min, max }\n return { status: 'valid', value: parsed }\n}\n\nexport function normalizeCoordinateInput(value: string | undefined): number | undefined {\n const trimmed = typeof value === 'string' ? value.trim() : ''\n if (!trimmed.length) return undefined\n const parsed = parseCoordinate(trimmed)\n return parsed === null ? undefined : parsed\n}\n"],
5
+ "mappings": "AAAO,MAAM,oBAAoB;AAAA,EAC/B,UAAU,EAAE,KAAK,KAAK,KAAK,GAAG;AAAA,EAC9B,WAAW,EAAE,KAAK,MAAM,KAAK,IAAI;AACnC;AAUA,MAAM,wBAAwB;AAE9B,SAAS,gBAAgB,SAAgC;AACvD,MAAI,CAAC,sBAAsB,KAAK,OAAO,EAAG,QAAO;AACjD,QAAM,SAAS,OAAO,WAAW,QAAQ,QAAQ,KAAK,GAAG,CAAC;AAC1D,SAAO,OAAO,SAAS,MAAM,IAAI,SAAS;AAC5C;AAEO,SAAS,wBACd,MACA,OAC4B;AAC5B,QAAM,UAAU,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI;AAC3D,MAAI,CAAC,QAAQ,OAAQ,QAAO,EAAE,QAAQ,QAAQ;AAC9C,QAAM,SAAS,gBAAgB,OAAO;AACtC,MAAI,WAAW,KAAM,QAAO,EAAE,QAAQ,UAAU;AAChD,QAAM,EAAE,KAAK,IAAI,IAAI,kBAAkB,IAAI;AAC3C,MAAI,SAAS,OAAO,SAAS,IAAK,QAAO,EAAE,QAAQ,cAAc,KAAK,IAAI;AAC1E,SAAO,EAAE,QAAQ,SAAS,OAAO,OAAO;AAC1C;AAEO,SAAS,yBAAyB,OAA+C;AACtF,QAAM,UAAU,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI;AAC3D,MAAI,CAAC,QAAQ,OAAQ,QAAO;AAC5B,QAAM,SAAS,gBAAgB,OAAO;AACtC,SAAO,WAAW,OAAO,SAAY;AACvC;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.6-develop.6386.1.391a1afb9e";
1
+ const APP_VERSION = "0.6.6-develop.6387.1.f4896ad565";
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.6386.1.391a1afb9e'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6387.1.f4896ad565'\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.6386.1.391a1afb9e",
3
+ "version": "0.6.6-develop.6387.1.f4896ad565",
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.4",
94
94
  "@mikro-orm/decorators": "^7.1.4",
95
95
  "@mikro-orm/postgresql": "^7.1.4",
96
- "@open-mercato/cache": "0.6.6-develop.6386.1.391a1afb9e",
96
+ "@open-mercato/cache": "0.6.6-develop.6387.1.f4896ad565",
97
97
  "dotenv": "^17.4.2",
98
98
  "rate-limiter-flexible": "^11.2.0",
99
99
  "re2js": "2.8.3",
@@ -4,6 +4,11 @@ import {
4
4
  } from '../subscriber'
5
5
  import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
6
6
 
7
+ // Give every entity a resolvable id so the real decrypt() path proceeds to decryptEntityPayload.
8
+ jest.mock('../entityIds', () => ({
9
+ resolveEntityIdFromMetadata: jest.fn(() => 'customers:customer_address'),
10
+ }))
11
+
7
12
  describe('decryptEntitiesWithFallbackScope subscriber memoization (issue #2235)', () => {
8
13
  const originalToggle = process.env.TENANT_DATA_ENCRYPTION
9
14
 
@@ -77,3 +82,71 @@ describe('decryptEntitiesWithFallbackScope subscriber memoization (issue #2235)'
77
82
  expect(spy).not.toHaveBeenCalled()
78
83
  })
79
84
  })
85
+
86
+ describe('decryptEntitiesWithFallbackScope per-row scope (multi-org safety)', () => {
87
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
88
+
89
+ afterEach(() => {
90
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
91
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
92
+ jest.restoreAllMocks()
93
+ })
94
+
95
+ // Records the (tenantId, organizationId) each row is decrypted with. `decryptEntityPayload` is the
96
+ // exact seam where the row's resolved scope is applied, so capturing its args proves which org key
97
+ // each row decrypts under.
98
+ function makeRecordingService(): {
99
+ service: TenantDataEncryptionService
100
+ calls: Array<{ tenantId: string | null; organizationId: string | null; row: Record<string, unknown> }>
101
+ } {
102
+ const calls: Array<{ tenantId: string | null; organizationId: string | null; row: Record<string, unknown> }> = []
103
+ const service = {
104
+ isEnabled: () => true,
105
+ decryptEntityPayload: jest.fn(
106
+ async (_entityId: string, target: Record<string, unknown>, tenantId: string | null, organizationId: string | null) => {
107
+ calls.push({ tenantId, organizationId, row: target })
108
+ return {}
109
+ },
110
+ ),
111
+ } as unknown as TenantDataEncryptionService
112
+ return { service, calls }
113
+ }
114
+
115
+ // A bare entity carrying its own scope + empty metadata (so the deep-decrypt prop loop is a no-op).
116
+ const row = (organizationId: string | null) => ({
117
+ organizationId,
118
+ tenantId: 'tenant-1',
119
+ __meta: { properties: {} },
120
+ })
121
+
122
+ it('decrypts each row with the row’s OWN organization, using the fallback org only when a row lacks one', async () => {
123
+ const { service, calls } = makeRecordingService()
124
+ const orgA = 'org-a'
125
+ const orgB = 'org-b'
126
+ const fallbackOrg = 'org-fallback'
127
+
128
+ const rowA = row(orgA)
129
+ const rowB = row(orgB)
130
+ const rowNoOrg = row(null)
131
+
132
+ // Mirrors the deals-map route under an "All organizations" scope: a single fallback org
133
+ // (orgFilterIds[0]) is passed, but the rows span multiple orgs.
134
+ await decryptEntitiesWithFallbackScope([rowA, rowB, rowNoOrg], {
135
+ em: {} as { getMetadata?: () => unknown; getComparator?: () => unknown },
136
+ tenantId: 'tenant-1',
137
+ organizationId: fallbackOrg,
138
+ encryptionService: service,
139
+ })
140
+
141
+ const orgFor = (target: Record<string, unknown>) =>
142
+ calls.find((call) => call.row === target)?.organizationId
143
+
144
+ // Each scoped row decrypts with its OWN org — NOT the orgFilterIds[0] fallback. This is what makes
145
+ // the route's single-fallback decryptionScope safe across a multi-org page.
146
+ expect(orgFor(rowA)).toBe(orgA)
147
+ expect(orgFor(rowB)).toBe(orgB)
148
+ // Only a row that carries no org of its own falls back to orgFilterIds[0].
149
+ expect(orgFor(rowNoOrg)).toBe(fallbackOrg)
150
+ expect(calls).toHaveLength(3)
151
+ })
152
+ })
@@ -0,0 +1,84 @@
1
+ import {
2
+ normalizeCoordinateInput,
3
+ validateCoordinateInput,
4
+ } from '../coordinates'
5
+
6
+ describe('normalizeCoordinateInput', () => {
7
+ it('returns undefined for undefined input', () => {
8
+ expect(normalizeCoordinateInput(undefined)).toBeUndefined()
9
+ })
10
+
11
+ it('returns undefined for empty string', () => {
12
+ expect(normalizeCoordinateInput('')).toBeUndefined()
13
+ })
14
+
15
+ it('returns undefined for whitespace-only input', () => {
16
+ expect(normalizeCoordinateInput(' ')).toBeUndefined()
17
+ })
18
+
19
+ it('parses a decimal value', () => {
20
+ expect(normalizeCoordinateInput('52.5')).toBe(52.5)
21
+ })
22
+
23
+ it('parses a negative value with surrounding whitespace', () => {
24
+ expect(normalizeCoordinateInput(' -13.37 ')).toBe(-13.37)
25
+ })
26
+
27
+ it('parses an integer value', () => {
28
+ expect(normalizeCoordinateInput('21')).toBe(21)
29
+ })
30
+
31
+ it('normalizes comma decimal separators', () => {
32
+ expect(normalizeCoordinateInput('52,5')).toBe(52.5)
33
+ })
34
+
35
+ it('returns undefined for non-numeric input', () => {
36
+ expect(normalizeCoordinateInput('abc')).toBeUndefined()
37
+ })
38
+
39
+ it('returns undefined for trailing junk after a number', () => {
40
+ expect(normalizeCoordinateInput('52.5abc')).toBeUndefined()
41
+ })
42
+
43
+ it('returns undefined for multiple separators', () => {
44
+ expect(normalizeCoordinateInput('52,5,5')).toBeUndefined()
45
+ })
46
+ })
47
+
48
+ describe('validateCoordinateInput', () => {
49
+ it('reports empty for undefined, empty, and whitespace values', () => {
50
+ expect(validateCoordinateInput('latitude', undefined)).toEqual({ status: 'empty' })
51
+ expect(validateCoordinateInput('latitude', '')).toEqual({ status: 'empty' })
52
+ expect(validateCoordinateInput('longitude', ' ')).toEqual({ status: 'empty' })
53
+ })
54
+
55
+ it('accepts valid latitude values including boundaries', () => {
56
+ expect(validateCoordinateInput('latitude', '52.5')).toEqual({ status: 'valid', value: 52.5 })
57
+ expect(validateCoordinateInput('latitude', '-90')).toEqual({ status: 'valid', value: -90 })
58
+ expect(validateCoordinateInput('latitude', '90')).toEqual({ status: 'valid', value: 90 })
59
+ })
60
+
61
+ it('accepts valid longitude values including boundaries', () => {
62
+ expect(validateCoordinateInput('longitude', '-180')).toEqual({ status: 'valid', value: -180 })
63
+ expect(validateCoordinateInput('longitude', '180')).toEqual({ status: 'valid', value: 180 })
64
+ })
65
+
66
+ it('accepts comma decimal separators', () => {
67
+ expect(validateCoordinateInput('latitude', '52,5')).toEqual({ status: 'valid', value: 52.5 })
68
+ })
69
+
70
+ it('flags latitude outside -90..90 as out of range', () => {
71
+ expect(validateCoordinateInput('latitude', '-91')).toEqual({ status: 'outOfRange', min: -90, max: 90 })
72
+ expect(validateCoordinateInput('latitude', '90.1')).toEqual({ status: 'outOfRange', min: -90, max: 90 })
73
+ })
74
+
75
+ it('flags longitude outside -180..180 as out of range', () => {
76
+ expect(validateCoordinateInput('longitude', '200')).toEqual({ status: 'outOfRange', min: -180, max: 180 })
77
+ expect(validateCoordinateInput('longitude', '-180.5')).toEqual({ status: 'outOfRange', min: -180, max: 180 })
78
+ })
79
+
80
+ it('flags non-numeric input as invalid', () => {
81
+ expect(validateCoordinateInput('latitude', 'abc')).toEqual({ status: 'invalid' })
82
+ expect(validateCoordinateInput('longitude', '12.3.4')).toEqual({ status: 'invalid' })
83
+ })
84
+ })
@@ -0,0 +1,40 @@
1
+ export const COORDINATE_RANGES = {
2
+ latitude: { min: -90, max: 90 },
3
+ longitude: { min: -180, max: 180 },
4
+ } as const
5
+
6
+ export type CoordinateFieldKind = keyof typeof COORDINATE_RANGES
7
+
8
+ export type CoordinateValidationResult =
9
+ | { status: 'empty' }
10
+ | { status: 'invalid' }
11
+ | { status: 'outOfRange'; min: number; max: number }
12
+ | { status: 'valid'; value: number }
13
+
14
+ const DECIMAL_INPUT_PATTERN = /^[+-]?(\d+([.,]\d+)?|[.,]\d+)$/
15
+
16
+ function parseCoordinate(trimmed: string): number | null {
17
+ if (!DECIMAL_INPUT_PATTERN.test(trimmed)) return null
18
+ const parsed = Number.parseFloat(trimmed.replace(',', '.'))
19
+ return Number.isFinite(parsed) ? parsed : null
20
+ }
21
+
22
+ export function validateCoordinateInput(
23
+ kind: CoordinateFieldKind,
24
+ value: string | undefined,
25
+ ): CoordinateValidationResult {
26
+ const trimmed = typeof value === 'string' ? value.trim() : ''
27
+ if (!trimmed.length) return { status: 'empty' }
28
+ const parsed = parseCoordinate(trimmed)
29
+ if (parsed === null) return { status: 'invalid' }
30
+ const { min, max } = COORDINATE_RANGES[kind]
31
+ if (parsed < min || parsed > max) return { status: 'outOfRange', min, max }
32
+ return { status: 'valid', value: parsed }
33
+ }
34
+
35
+ export function normalizeCoordinateInput(value: string | undefined): number | undefined {
36
+ const trimmed = typeof value === 'string' ? value.trim() : ''
37
+ if (!trimmed.length) return undefined
38
+ const parsed = parseCoordinate(trimmed)
39
+ return parsed === null ? undefined : parsed
40
+ }