@open-mercato/shared 0.6.6-develop.6385.1.9a81faa5f0 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/location/coordinates.js +31 -0
- package/dist/lib/location/coordinates.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/encryption/__tests__/subscriber.test.ts +73 -0
- package/src/lib/location/__tests__/coordinates.test.ts +84 -0
- package/src/lib/location/coordinates.ts +40 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
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
|
+
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|