@open-mercato/shared 0.9.1-develop.7575.1.8480826a9b → 0.10.1-develop.7577.1.477f2486e5
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/AGENTS.md +4 -4
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +46 -6
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +4 -3
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +4 -0
- package/dist/lib/catalog-visibility/index.js.map +2 -2
- package/dist/lib/commands/command-bus.js +45 -13
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/exporters.js +1 -1
- package/dist/lib/crud/exporters.js.map +2 -2
- package/dist/lib/crud/ids.js +13 -1
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/db/advisoryLock.js +172 -0
- package/dist/lib/db/advisoryLock.js.map +7 -0
- package/dist/lib/db/pg-errors.js +14 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/localization/index.js +9 -1
- package/dist/lib/localization/index.js.map +2 -2
- package/dist/lib/localization/translatable-fields.js +26 -0
- package/dist/lib/localization/translatable-fields.js.map +2 -2
- package/dist/lib/number.js +6 -0
- package/dist/lib/number.js.map +2 -2
- package/dist/lib/openapi/generator.js +84 -67
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/query/engine.js +45 -10
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/join-utils.js +1 -1
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/query/overlap.js +58 -0
- package/dist/lib/query/overlap.js.map +7 -0
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/url.js +24 -0
- package/dist/lib/url.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +7 -3
- package/dist/lib/webhooks/body.js.map +2 -2
- package/dist/lib/webhooks/index.js +2 -0
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/notifications/safeHref.js +20 -0
- package/dist/modules/notifications/safeHref.js.map +7 -0
- package/dist/modules/notifications/types.js +4 -0
- package/dist/modules/notifications/types.js.map +3 -3
- package/dist/modules/payment_gateways/types.js +14 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +14 -3
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/__tests__/number.test.ts +24 -1
- package/src/lib/__tests__/url.cross-site.test.ts +140 -0
- package/src/lib/auth/__tests__/server.test.ts +14 -0
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
- package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
- package/src/lib/bootstrap/dynamicLoader.ts +63 -5
- package/src/lib/bootstrap/factory.ts +8 -3
- package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
- package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
- package/src/lib/bootstrap/types.ts +5 -1
- package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
- package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
- package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
- package/src/lib/catalog-visibility/index.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
- package/src/lib/commands/command-bus.ts +42 -10
- package/src/lib/commands/command-interceptor.ts +4 -0
- package/src/lib/commands/types.ts +14 -0
- package/src/lib/crud/__tests__/exporters.test.ts +18 -0
- package/src/lib/crud/__tests__/ids.test.ts +34 -1
- package/src/lib/crud/exporters.ts +1 -1
- package/src/lib/crud/ids.ts +24 -0
- package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
- package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
- package/src/lib/db/advisoryLock.ts +271 -0
- package/src/lib/db/pg-errors.ts +35 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
- package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
- package/src/lib/localization/index.ts +8 -1
- package/src/lib/localization/translatable-fields.ts +46 -0
- package/src/lib/number.ts +13 -0
- package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
- package/src/lib/openapi/generator.ts +106 -77
- package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
- package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
- package/src/lib/query/__tests__/overlap.test.ts +219 -0
- package/src/lib/query/engine.ts +58 -8
- package/src/lib/query/join-utils.ts +1 -1
- package/src/lib/query/overlap.ts +106 -0
- package/src/lib/query/types.ts +13 -1
- package/src/lib/url.ts +30 -0
- package/src/lib/webhooks/__tests__/body.test.ts +48 -0
- package/src/lib/webhooks/body.ts +11 -4
- package/src/lib/webhooks/index.ts +1 -0
- package/src/modules/integrations/types.ts +6 -0
- package/src/modules/notifications/safeHref.ts +19 -0
- package/src/modules/notifications/types.ts +7 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
- package/src/modules/payment_gateways/types.ts +52 -6
- package/src/modules/registry.ts +5 -0
- package/src/modules/search.ts +39 -0
- package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
- package/src/modules/widgets/injection-loader.ts +33 -3
package/src/lib/query/engine.ts
CHANGED
|
@@ -38,6 +38,13 @@ import { resolveListCountCap } from './count-cap'
|
|
|
38
38
|
import { mapWithConcurrency } from './bounded-decrypt'
|
|
39
39
|
import { parseNumberWithDefault } from '../number'
|
|
40
40
|
import { createLogger } from '../logger'
|
|
41
|
+
import {
|
|
42
|
+
buildArrayColumnNoOverlapPredicate,
|
|
43
|
+
buildArrayColumnOverlapPredicate,
|
|
44
|
+
buildJsonbNoOverlapPredicate,
|
|
45
|
+
buildJsonbOverlapPredicate,
|
|
46
|
+
buildScalarOverlapPredicate,
|
|
47
|
+
} from './overlap'
|
|
41
48
|
|
|
42
49
|
const logger = createLogger('shared').child({ component: 'query' })
|
|
43
50
|
|
|
@@ -1216,7 +1223,11 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1216
1223
|
})
|
|
1217
1224
|
continue
|
|
1218
1225
|
}
|
|
1219
|
-
q =
|
|
1226
|
+
q = f.op === 'overlap'
|
|
1227
|
+
? q.where(buildScalarOverlapPredicate(expr, f.value))
|
|
1228
|
+
: f.op === 'noverlap'
|
|
1229
|
+
? q.where(sql<boolean>`false`)
|
|
1230
|
+
: this.applyColumnOp(q, expr, f.op, f.value)
|
|
1220
1231
|
}
|
|
1221
1232
|
|
|
1222
1233
|
// OR groups are applied here, after the cf:* value expressions exist, so a
|
|
@@ -1263,7 +1274,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1263
1274
|
value: rf.value,
|
|
1264
1275
|
})
|
|
1265
1276
|
}
|
|
1266
|
-
|
|
1277
|
+
if (rf.op === 'noverlap') return sql<boolean>`false`
|
|
1278
|
+
return rf.op === 'overlap'
|
|
1279
|
+
? buildScalarOverlapPredicate(cfValueExprByKey[rf.key], rf.value)
|
|
1280
|
+
: this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
|
|
1267
1281
|
}
|
|
1268
1282
|
return this.buildIndexDocOpExpression(eb, {
|
|
1269
1283
|
entity: String(entity),
|
|
@@ -1589,10 +1603,22 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1589
1603
|
return builder.where(column as any, '<', value as any)
|
|
1590
1604
|
case 'lte':
|
|
1591
1605
|
return builder.where(column as any, '<=', value as any)
|
|
1592
|
-
case 'in':
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1606
|
+
case 'in': {
|
|
1607
|
+
// An empty list ("no id survived validation", #7502) compiles to invalid
|
|
1608
|
+
// SQL (`in ()`) under Kysely/Postgres — short-circuit to a predicate that
|
|
1609
|
+
// never matches instead of letting the driver reject the query.
|
|
1610
|
+
const inValues = Array.isArray(value) ? value : [value]
|
|
1611
|
+
return inValues.length === 0
|
|
1612
|
+
? builder.where(sql<boolean>`false`)
|
|
1613
|
+
: builder.where(column as any, 'in', inValues)
|
|
1614
|
+
}
|
|
1615
|
+
case 'nin': {
|
|
1616
|
+
// Mirrors 'in' above: empty means "exclude nothing".
|
|
1617
|
+
const ninValues = Array.isArray(value) ? value : [value]
|
|
1618
|
+
return ninValues.length === 0
|
|
1619
|
+
? builder.where(sql<boolean>`true`)
|
|
1620
|
+
: builder.where(column as any, 'not in', ninValues)
|
|
1621
|
+
}
|
|
1596
1622
|
case 'like':
|
|
1597
1623
|
return builder.where(column as any, 'like', value as any)
|
|
1598
1624
|
case 'ilike':
|
|
@@ -1601,6 +1627,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1601
1627
|
return value
|
|
1602
1628
|
? builder.where(column as any, 'is not', null)
|
|
1603
1629
|
: builder.where(column as any, 'is', null)
|
|
1630
|
+
case 'overlap':
|
|
1631
|
+
return builder.where(buildArrayColumnOverlapPredicate(column, value))
|
|
1632
|
+
case 'noverlap':
|
|
1633
|
+
return builder.where(buildArrayColumnNoOverlapPredicate(column, value))
|
|
1604
1634
|
default:
|
|
1605
1635
|
return builder
|
|
1606
1636
|
}
|
|
@@ -1734,6 +1764,11 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1734
1764
|
case 'exists':
|
|
1735
1765
|
predicate = sql<boolean>`${caseExpr} is not null`
|
|
1736
1766
|
break
|
|
1767
|
+
case 'overlap':
|
|
1768
|
+
predicate = buildScalarOverlapPredicate(caseExpr, value)
|
|
1769
|
+
break
|
|
1770
|
+
case 'noverlap':
|
|
1771
|
+
return sql<boolean>`false`
|
|
1737
1772
|
default:
|
|
1738
1773
|
// Mirrors buildColumnOpExpression's unknown-op fallback: a neutral
|
|
1739
1774
|
// predicate, so full and count shapes drop the same leaves.
|
|
@@ -1751,11 +1786,20 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1751
1786
|
case 'gte': return eb(column, '>=', value)
|
|
1752
1787
|
case 'lt': return eb(column, '<', value)
|
|
1753
1788
|
case 'lte': return eb(column, '<=', value)
|
|
1754
|
-
case 'in':
|
|
1755
|
-
|
|
1789
|
+
case 'in': {
|
|
1790
|
+
// Mirrors the guard in applyColumnOp above (#7502).
|
|
1791
|
+
const inValues = Array.isArray(value) ? value : [value]
|
|
1792
|
+
return inValues.length === 0 ? eb.val(false) : eb(column, 'in', inValues)
|
|
1793
|
+
}
|
|
1794
|
+
case 'nin': {
|
|
1795
|
+
const ninValues = Array.isArray(value) ? value : [value]
|
|
1796
|
+
return ninValues.length === 0 ? eb.val(true) : eb(column, 'not in', ninValues)
|
|
1797
|
+
}
|
|
1756
1798
|
case 'like': return eb(column, 'like', value)
|
|
1757
1799
|
case 'ilike': return eb(column, 'ilike', value)
|
|
1758
1800
|
case 'exists': return value ? eb(column, 'is not', null) : eb(column, 'is', null)
|
|
1801
|
+
case 'overlap': return buildArrayColumnOverlapPredicate(column, value)
|
|
1802
|
+
case 'noverlap': return buildArrayColumnNoOverlapPredicate(column, value)
|
|
1759
1803
|
default: return eb.val(true)
|
|
1760
1804
|
}
|
|
1761
1805
|
}
|
|
@@ -1973,6 +2017,12 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1973
2017
|
? sub.where(sql<boolean>`${textExpr} is not null`)
|
|
1974
2018
|
: sub.where(sql<boolean>`${textExpr} is null`)
|
|
1975
2019
|
break
|
|
2020
|
+
case 'overlap':
|
|
2021
|
+
sub = sub.where(buildJsonbOverlapPredicate(`${alias}.doc`, opts.field, opts.value))
|
|
2022
|
+
break
|
|
2023
|
+
case 'noverlap':
|
|
2024
|
+
sub = sub.where(buildJsonbNoOverlapPredicate(`${alias}.doc`, opts.field, opts.value))
|
|
2025
|
+
break
|
|
1976
2026
|
default:
|
|
1977
2027
|
break
|
|
1978
2028
|
}
|
|
@@ -7,7 +7,7 @@ type AnyBuilder = any
|
|
|
7
7
|
|
|
8
8
|
export type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null }
|
|
9
9
|
|
|
10
|
-
const VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'] as const
|
|
10
|
+
const VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists', 'overlap', 'noverlap'] as const
|
|
11
11
|
|
|
12
12
|
const normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)
|
|
13
13
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { sql, type RawBuilder } from 'kysely'
|
|
2
|
+
|
|
3
|
+
const LITERAL_SAFE_DOC_KEY = /^[A-Za-z0-9_:.-]{1,128}$/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes the right-hand side of an `overlap` filter into a de-duplicated list of strings.
|
|
7
|
+
*
|
|
8
|
+
* `null`/`undefined` entries are dropped; a scalar is treated as a one-element list. An empty
|
|
9
|
+
* result means the filter matches nothing — every `overlap` builder compiles it to `false`
|
|
10
|
+
* rather than dropping the predicate, so a caller that computes an empty inclusion set can
|
|
11
|
+
* never widen a query by accident.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeOverlapValues(value: unknown): string[] {
|
|
14
|
+
const list: readonly unknown[] = Array.isArray(value)
|
|
15
|
+
? value
|
|
16
|
+
: value === undefined || value === null
|
|
17
|
+
? []
|
|
18
|
+
: [value]
|
|
19
|
+
const out: string[] = []
|
|
20
|
+
const seen = new Set<string>()
|
|
21
|
+
for (const entry of list) {
|
|
22
|
+
if (entry === undefined || entry === null) continue
|
|
23
|
+
const normalized = String(entry)
|
|
24
|
+
if (seen.has(normalized)) continue
|
|
25
|
+
seen.add(normalized)
|
|
26
|
+
out.push(normalized)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `(<docColumn> -> '<key>')` — the jsonb sub-document of an index document.
|
|
33
|
+
*
|
|
34
|
+
* The key is emitted as an escaped SQL literal when it is a plain identifier so the expression is
|
|
35
|
+
* textually identical to a GIN expression index such as `((doc -> 'scope_keys'))` and the planner
|
|
36
|
+
* can match it. Any other key falls back to a bound parameter (still correct, just not indexable).
|
|
37
|
+
*/
|
|
38
|
+
export function jsonbDocKeyExpression(docColumnRef: string, key: string): RawBuilder<unknown> {
|
|
39
|
+
const keyExpr = LITERAL_SAFE_DOC_KEY.test(key) ? sql.lit(key) : sql`${key}`
|
|
40
|
+
return sql`(${sql.ref(docColumnRef)} -> ${keyExpr})`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* jsonb "has any of" over a string-array key of an index document:
|
|
45
|
+
* `(<docColumn> -> '<key>') ?| $n::text[]`. The values are bound as one array parameter.
|
|
46
|
+
* A missing key or a JSON `null` evaluates to SQL NULL, i.e. the row does not match.
|
|
47
|
+
*/
|
|
48
|
+
export function buildJsonbOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {
|
|
49
|
+
const values = normalizeOverlapValues(value)
|
|
50
|
+
if (!values.length) return sql<boolean>`false`
|
|
51
|
+
return sql<boolean>`${jsonbDocKeyExpression(docColumnRef, key)} ?| ${values}::text[]`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* jsonb "has none of" over a string-array key of an index document (`noverlap`):
|
|
56
|
+
* `(jsonb_typeof(<expr>) = 'array' and not (<expr> ?| $n::text[]))`.
|
|
57
|
+
*
|
|
58
|
+
* Only a document whose key holds a JSON array can match: a missing key, a JSON `null` or a
|
|
59
|
+
* scalar never satisfies it, so an unindexed document can never slip through an exclusion.
|
|
60
|
+
* An empty value list excludes nothing (only the array check remains).
|
|
61
|
+
*/
|
|
62
|
+
export function buildJsonbNoOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {
|
|
63
|
+
const values = normalizeOverlapValues(value)
|
|
64
|
+
const keyExpr = jsonbDocKeyExpression(docColumnRef, key)
|
|
65
|
+
const isArray = sql<boolean>`jsonb_typeof(${keyExpr}) = 'array'`
|
|
66
|
+
if (!values.length) return isArray
|
|
67
|
+
return sql<boolean>`(${isArray} and not (${keyExpr} ?| ${values}::text[]))`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Postgres array overlap for array-typed columns: `<column> && $n`. The bound array takes its
|
|
72
|
+
* element type from the column, so `text[]`, `varchar[]` and `uuid[]` columns all work.
|
|
73
|
+
*/
|
|
74
|
+
export function buildArrayColumnOverlapPredicate(
|
|
75
|
+
column: string | RawBuilder<unknown>,
|
|
76
|
+
value: unknown,
|
|
77
|
+
): RawBuilder<boolean> {
|
|
78
|
+
const values = normalizeOverlapValues(value)
|
|
79
|
+
if (!values.length) return sql<boolean>`false`
|
|
80
|
+
const columnExpr = typeof column === 'string' ? sql.ref(column) : column
|
|
81
|
+
return sql<boolean>`${columnExpr} && ${values}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Postgres array "has none of" for array-typed columns (`noverlap`): `not (<column> && $n)`.
|
|
86
|
+
* A NULL column never matches; an empty value list excludes nothing (`<column> is not null`).
|
|
87
|
+
*/
|
|
88
|
+
export function buildArrayColumnNoOverlapPredicate(
|
|
89
|
+
column: string | RawBuilder<unknown>,
|
|
90
|
+
value: unknown,
|
|
91
|
+
): RawBuilder<boolean> {
|
|
92
|
+
const values = normalizeOverlapValues(value)
|
|
93
|
+
const columnExpr = typeof column === 'string' ? sql.ref(column) : column
|
|
94
|
+
if (!values.length) return sql<boolean>`${columnExpr} is not null`
|
|
95
|
+
return sql<boolean>`not (${columnExpr} && ${values})`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `overlap` over a scalar expression evaluated once per stored value (custom-field value rows):
|
|
100
|
+
* the record matches when any of its values is one of the requested ones — `<expr> in (...)`.
|
|
101
|
+
*/
|
|
102
|
+
export function buildScalarOverlapPredicate(expression: RawBuilder<unknown>, value: unknown): RawBuilder<boolean> {
|
|
103
|
+
const values = normalizeOverlapValues(value)
|
|
104
|
+
if (!values.length) return sql<boolean>`false`
|
|
105
|
+
return sql<boolean>`${expression} in (${sql.join(values.map((entry) => sql`${entry}`), sql`, `)})`
|
|
106
|
+
}
|
package/src/lib/query/types.ts
CHANGED
|
@@ -2,7 +2,17 @@ import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
|
2
2
|
import type { Profiler } from '../profiler'
|
|
3
3
|
import type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `overlap` (additive, 2026-10-05): "has any of" over a multi-valued field. An index-document
|
|
7
|
+
* string array compiles to jsonb `?|`, a `cf:*` key to any-value-in-set, and an array-typed base
|
|
8
|
+
* column to `&&`. An empty value list matches nothing.
|
|
9
|
+
*
|
|
10
|
+
* `noverlap` (additive, 2026-10-05): "has none of" — the negation of `overlap` on an index-document
|
|
11
|
+
* string array or an array-typed base column. Only a present array can match (a missing/null key or
|
|
12
|
+
* NULL column never does); an empty value list excludes nothing. Unsupported on `cf:*` keys, where
|
|
13
|
+
* it compiles to `false`.
|
|
14
|
+
*/
|
|
15
|
+
export type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists' | 'overlap' | 'noverlap'
|
|
6
16
|
|
|
7
17
|
export enum SortDir {
|
|
8
18
|
Asc = 'asc',
|
|
@@ -34,6 +44,8 @@ export type WhereOps<T> = {
|
|
|
34
44
|
$like?: T extends string ? string : never
|
|
35
45
|
$ilike?: T extends string ? string : never
|
|
36
46
|
$exists?: boolean
|
|
47
|
+
$overlap?: T extends readonly (infer Element)[] ? Element[] : T[]
|
|
48
|
+
$noverlap?: T extends readonly (infer Element)[] ? Element[] : T[]
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
// A field filter can be a direct value (equals) or ops object
|
package/src/lib/url.ts
CHANGED
|
@@ -267,6 +267,36 @@ export function toSecurityEmailUrl(input: RequestInput, path: string, env: EnvLi
|
|
|
267
267
|
return `${base}/${path.replace(/^\/+/, '')}`
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
export function isCrossSiteBrowserRequest(req: Request, env: EnvLike = process.env): boolean {
|
|
271
|
+
const rawFetchSite = req.headers.get('sec-fetch-site')
|
|
272
|
+
const fetchSite = rawFetchSite?.trim().toLowerCase() ?? null
|
|
273
|
+
if (fetchSite === 'cross-site') return true
|
|
274
|
+
if (fetchSite === 'same-origin' || fetchSite === 'none') return false
|
|
275
|
+
|
|
276
|
+
const rawOrigin = req.headers.get('origin')
|
|
277
|
+
if (rawOrigin === null) return rawFetchSite !== null
|
|
278
|
+
const origin = normalizeOrigin(rawOrigin)
|
|
279
|
+
if (!origin) return true
|
|
280
|
+
|
|
281
|
+
const allowedOrigins = readAllowedOrigins(env)
|
|
282
|
+
if (allowedOrigins.has(origin)) return false
|
|
283
|
+
if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) return false
|
|
284
|
+
|
|
285
|
+
const forwardedProto = readFirstHeaderValue(req.headers.get('x-forwarded-proto'))
|
|
286
|
+
const protocol = forwardedProto
|
|
287
|
+
? (forwardedProto.endsWith(':') ? forwardedProto : `${forwardedProto}:`)
|
|
288
|
+
: new URL(origin).protocol
|
|
289
|
+
const host = req.headers.get('host')
|
|
290
|
+
const forwardedHost = req.headers.get('x-forwarded-host')
|
|
291
|
+
if (originFromHost(protocol, forwardedHost) === origin) return false
|
|
292
|
+
if (originFromHost(protocol, host) === origin) return false
|
|
293
|
+
|
|
294
|
+
if (env.NODE_ENV !== 'test') {
|
|
295
|
+
logger.warn('Cross-site request rejected', { origin, host, forwardedHost })
|
|
296
|
+
}
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
270
300
|
export function mapSecurityEmailUrlError(
|
|
271
301
|
error: unknown,
|
|
272
302
|
mapping: SecurityEmailUrlErrorMapping,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,
|
|
4
4
|
readBoundedRequestBody,
|
|
5
|
+
readBoundedRequestBytes,
|
|
5
6
|
resolveWebhookBodyLimitBytes,
|
|
6
7
|
WebhookBodyTooLargeError,
|
|
7
8
|
} from '../body'
|
|
@@ -77,6 +78,53 @@ describe('readBoundedRequestBody', () => {
|
|
|
77
78
|
})
|
|
78
79
|
})
|
|
79
80
|
|
|
81
|
+
describe('readBoundedRequestBytes', () => {
|
|
82
|
+
it('preserves exact bytes including invalid UTF-8', async () => {
|
|
83
|
+
const bytes = new Uint8Array([0xff, 0xfe, 0x41, 0xc3, 0x28])
|
|
84
|
+
const { request } = makeStreamingRequest([bytes.slice(0, 2), bytes.slice(2)])
|
|
85
|
+
|
|
86
|
+
const result = await readBoundedRequestBytes(request, { maxBytes: bytes.byteLength })
|
|
87
|
+
|
|
88
|
+
expect(Array.from(result)).toEqual(Array.from(bytes))
|
|
89
|
+
expect(new TextDecoder().decode(result)).toContain('\uFFFD')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns an empty array for a missing body', async () => {
|
|
93
|
+
const request = new Request('http://localhost/webhook', { method: 'POST' })
|
|
94
|
+
|
|
95
|
+
expect((await readBoundedRequestBytes(request)).byteLength).toBe(0)
|
|
96
|
+
await expect(readBoundedRequestBody(request)).resolves.toBe('')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('enforces the declared and streamed limits and cancels the stream', async () => {
|
|
100
|
+
const declared = new Request('http://localhost/webhook', {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'content-length': '6' },
|
|
103
|
+
body: 'ok',
|
|
104
|
+
})
|
|
105
|
+
await expect(readBoundedRequestBytes(declared, { maxBytes: 5 })).rejects.toBeInstanceOf(
|
|
106
|
+
WebhookBodyTooLargeError,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const { request, cancel } = makeStreamingRequest([
|
|
110
|
+
new Uint8Array([1, 2, 3]),
|
|
111
|
+
new Uint8Array([4, 5, 6]),
|
|
112
|
+
new Uint8Array([7, 8, 9]),
|
|
113
|
+
])
|
|
114
|
+
await expect(readBoundedRequestBytes(request, { maxBytes: 5 })).rejects.toBeInstanceOf(
|
|
115
|
+
WebhookBodyTooLargeError,
|
|
116
|
+
)
|
|
117
|
+
expect(cancel).toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('keeps the string reader decoding the same bytes', async () => {
|
|
121
|
+
const bytes = new TextEncoder().encode('zażółć')
|
|
122
|
+
const { request } = makeStreamingRequest([bytes])
|
|
123
|
+
|
|
124
|
+
await expect(readBoundedRequestBody(request)).resolves.toBe('zażółć')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
80
128
|
describe('resolveWebhookBodyLimitBytes', () => {
|
|
81
129
|
it('uses the documented default for missing or invalid configuration', () => {
|
|
82
130
|
expect(resolveWebhookBodyLimitBytes(undefined)).toBe(DEFAULT_WEBHOOK_BODY_LIMIT_BYTES)
|
package/src/lib/webhooks/body.ts
CHANGED
|
@@ -23,10 +23,10 @@ export function resolveWebhookBodyLimitBytes(
|
|
|
23
23
|
return Number.isSafeInteger(parsed) ? parsed : fallback
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export async function
|
|
26
|
+
export async function readBoundedRequestBytes(
|
|
27
27
|
request: Request,
|
|
28
28
|
options?: { maxBytes?: number },
|
|
29
|
-
): Promise<
|
|
29
|
+
): Promise<Uint8Array> {
|
|
30
30
|
const configuredLimit = options?.maxBytes ?? resolveWebhookBodyLimitBytes()
|
|
31
31
|
const maxBytes = Number.isSafeInteger(configuredLimit) && configuredLimit > 0
|
|
32
32
|
? configuredLimit
|
|
@@ -39,7 +39,7 @@ export async function readBoundedRequestBody(
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
if (!request.body) return
|
|
42
|
+
if (!request.body) return new Uint8Array(0)
|
|
43
43
|
|
|
44
44
|
const reader = request.body.getReader()
|
|
45
45
|
const chunks: Uint8Array[] = []
|
|
@@ -66,5 +66,12 @@ export async function readBoundedRequestBody(
|
|
|
66
66
|
body.set(chunk, offset)
|
|
67
67
|
offset += chunk.byteLength
|
|
68
68
|
}
|
|
69
|
-
return
|
|
69
|
+
return body
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readBoundedRequestBody(
|
|
73
|
+
request: Request,
|
|
74
|
+
options?: { maxBytes?: number },
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
return new TextDecoder().decode(await readBoundedRequestBytes(request, options))
|
|
70
77
|
}
|
|
@@ -157,6 +157,12 @@ export interface IntegrationDetailPageConfig {
|
|
|
157
157
|
* Provider modules can replace these with injected tabs using the same spot.
|
|
158
158
|
*/
|
|
159
159
|
hiddenTabs?: IntegrationDetailBuiltInTab[]
|
|
160
|
+
/**
|
|
161
|
+
* Detail-page tab that holds the provider's Connect / Reconnect controls (a built-in tab id or
|
|
162
|
+
* an injected tab id). The OAuth reauth banner links to `?tab=<connectTabId>` while that tab is
|
|
163
|
+
* visible and the viewer holds `integrations.credentials.manage`.
|
|
164
|
+
*/
|
|
165
|
+
connectTabId?: string
|
|
160
166
|
}
|
|
161
167
|
|
|
162
168
|
export type IntegrationDetailBuiltInTab =
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const SENTINEL_ORIGIN = 'https://notifications.open-mercato.local'
|
|
2
|
+
const CONTROL_OR_BACKSLASH = /[\u0000-\u001F\u007F\\]/
|
|
3
|
+
|
|
4
|
+
export function isSafeNotificationHref(href: string): boolean {
|
|
5
|
+
if (!href.startsWith('/') || href.startsWith('//')) {
|
|
6
|
+
return false
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (CONTROL_OR_BACKSLASH.test(href)) {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(href, SENTINEL_ORIGIN)
|
|
15
|
+
return parsed.origin === SENTINEL_ORIGIN && !parsed.pathname.startsWith('//')
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -3,6 +3,13 @@ import type { ComponentType } from 'react'
|
|
|
3
3
|
export type NotificationStatus = 'unread' | 'read' | 'actioned' | 'dismissed'
|
|
4
4
|
export type NotificationSeverity = 'info' | 'warning' | 'success' | 'error'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* `code` of the 409 body `POST /api/notifications/:id/action` answers with when the notification's
|
|
8
|
+
* action has already been executed. Lets a client tell that refusal apart from a 409 raised by
|
|
9
|
+
* the action's own command.
|
|
10
|
+
*/
|
|
11
|
+
export const NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE = 'notification_action_already_executed'
|
|
12
|
+
|
|
6
13
|
export type NotificationAction = {
|
|
7
14
|
id: string
|
|
8
15
|
label: string
|
|
@@ -3,6 +3,9 @@ import {
|
|
|
3
3
|
getWebhookHandler,
|
|
4
4
|
registerWebhookHandler,
|
|
5
5
|
type VerifyWebhookInput,
|
|
6
|
+
type WebhookLocatorContext,
|
|
7
|
+
type WebhookResponseOutcome,
|
|
8
|
+
WebhookVerificationUnavailableError,
|
|
6
9
|
type WebhookEvent,
|
|
7
10
|
} from '../types'
|
|
8
11
|
|
|
@@ -34,4 +37,45 @@ describe('payment gateway webhook registration', () => {
|
|
|
34
37
|
expect(getWebhookHandler('invalid')).toBeUndefined()
|
|
35
38
|
},
|
|
36
39
|
)
|
|
40
|
+
|
|
41
|
+
it('stays compatible with one-argument locator hints', () => {
|
|
42
|
+
const readSessionIdHint = (payload: Record<string, unknown> | null) => (payload ? 'sess' : null)
|
|
43
|
+
registerWebhookHandler('legacy', handler, { readSessionIdHint })
|
|
44
|
+
|
|
45
|
+
expect(getWebhookHandler('legacy')?.readSessionIdHint?.({})).toBe('sess')
|
|
46
|
+
expect(getWebhookHandler('legacy')?.rawBody).toBeUndefined()
|
|
47
|
+
expect(getWebhookHandler('legacy')?.formatResponse).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('passes through the optional extension hooks', () => {
|
|
51
|
+
const readSessionIdHint = (_payload: Record<string, unknown> | null, context?: WebhookLocatorContext) =>
|
|
52
|
+
context ? String(context.headers['x-id']) : null
|
|
53
|
+
const readPaymentIdHint = () => 'pay'
|
|
54
|
+
const formatResponse = (outcome: WebhookResponseOutcome) => ({ status: 200, body: outcome })
|
|
55
|
+
registerWebhookHandler('extended', handler, {
|
|
56
|
+
readSessionIdHint,
|
|
57
|
+
readPaymentIdHint,
|
|
58
|
+
rawBody: 'bytes',
|
|
59
|
+
formatResponse,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const registration = getWebhookHandler('extended')
|
|
63
|
+
expect(registration?.rawBody).toBe('bytes')
|
|
64
|
+
expect(registration?.readPaymentIdHint).toBe(readPaymentIdHint)
|
|
65
|
+
expect(registration?.formatResponse).toBe(formatResponse)
|
|
66
|
+
expect(registration?.readSessionIdHint?.(null, { rawBody: '', headers: { 'x-id': 'abc' } })).toBe('abc')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rejects an unknown rawBody mode', () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
registerWebhookHandler('bad', handler, { rawBody: 'stream' as unknown as 'text' }),
|
|
72
|
+
).toThrow('[internal] Payment gateway webhook rawBody must be')
|
|
73
|
+
expect(getWebhookHandler('bad')).toBeUndefined()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('exposes a typed verification-unavailable error', () => {
|
|
77
|
+
const error = new WebhookVerificationUnavailableError()
|
|
78
|
+
expect(error).toBeInstanceOf(Error)
|
|
79
|
+
expect(error.name).toBe('WebhookVerificationUnavailableError')
|
|
80
|
+
})
|
|
37
81
|
})
|
|
@@ -237,6 +237,45 @@ export interface VerifyWebhookInput {
|
|
|
237
237
|
rawBody: string | Buffer
|
|
238
238
|
headers: Record<string, string | string[] | undefined>
|
|
239
239
|
credentials: Record<string, unknown>
|
|
240
|
+
/** Platform-resolved payment the webhook is expected to concern, when a locator found one. */
|
|
241
|
+
candidate?: WebhookCandidateSnapshot
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Raw request material handed to webhook locator hints. */
|
|
245
|
+
export interface WebhookLocatorContext {
|
|
246
|
+
rawBody: string | Buffer
|
|
247
|
+
headers: Record<string, string | string[] | undefined>
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface WebhookCandidateSnapshot {
|
|
251
|
+
transactionId: string
|
|
252
|
+
paymentId: string
|
|
253
|
+
providerSessionId: string | null
|
|
254
|
+
amount: string
|
|
255
|
+
currencyCode: string
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export type WebhookResponseOutcome =
|
|
259
|
+
| 'accepted'
|
|
260
|
+
| 'no_candidate'
|
|
261
|
+
| 'verification_failed'
|
|
262
|
+
| 'verification_unavailable'
|
|
263
|
+
| 'processing_failed'
|
|
264
|
+
| 'payload_too_large'
|
|
265
|
+
| 'rate_limited'
|
|
266
|
+
|
|
267
|
+
export interface WebhookHttpResponse {
|
|
268
|
+
status: number
|
|
269
|
+
body: string | Record<string, unknown>
|
|
270
|
+
contentType?: string
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Thrown by a webhook handler when verification cannot be performed (for example a provider outage). */
|
|
274
|
+
export class WebhookVerificationUnavailableError extends Error {
|
|
275
|
+
constructor(message = 'Webhook verification is unavailable') {
|
|
276
|
+
super(message)
|
|
277
|
+
this.name = 'WebhookVerificationUnavailableError'
|
|
278
|
+
}
|
|
240
279
|
}
|
|
241
280
|
|
|
242
281
|
export interface WebhookEvent {
|
|
@@ -252,10 +291,15 @@ export interface WebhookEvent {
|
|
|
252
291
|
export interface WebhookHandlerRegistration {
|
|
253
292
|
handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>
|
|
254
293
|
queue?: string
|
|
255
|
-
readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null
|
|
294
|
+
readSessionIdHint?: (payload: Record<string, unknown> | null, context?: WebhookLocatorContext) => string | null
|
|
295
|
+
readPaymentIdHint?: (payload: Record<string, unknown> | null, context?: WebhookLocatorContext) => string | null
|
|
256
296
|
maxBodyBytes?: number
|
|
297
|
+
rawBody?: WebhookRawBodyMode
|
|
298
|
+
formatResponse?: (outcome: WebhookResponseOutcome) => WebhookHttpResponse
|
|
257
299
|
}
|
|
258
300
|
|
|
301
|
+
export type WebhookRawBodyMode = 'text' | 'bytes'
|
|
302
|
+
|
|
259
303
|
// ── Adapter Registry Options ────────────────────────────────────────────────
|
|
260
304
|
|
|
261
305
|
export interface RegisterAdapterOptions {
|
|
@@ -345,12 +389,11 @@ export function clearGatewayAdapters(): void {
|
|
|
345
389
|
export function registerWebhookHandler(
|
|
346
390
|
providerKey: string,
|
|
347
391
|
handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>,
|
|
348
|
-
options?:
|
|
349
|
-
queue?: string
|
|
350
|
-
readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null
|
|
351
|
-
maxBodyBytes?: number
|
|
352
|
-
},
|
|
392
|
+
options?: Omit<WebhookHandlerRegistration, 'handler'>,
|
|
353
393
|
): () => void {
|
|
394
|
+
if (options?.rawBody !== undefined && options.rawBody !== 'text' && options.rawBody !== 'bytes') {
|
|
395
|
+
throw new Error("[internal] Payment gateway webhook rawBody must be 'text' or 'bytes'")
|
|
396
|
+
}
|
|
354
397
|
if (
|
|
355
398
|
options?.maxBodyBytes !== undefined
|
|
356
399
|
&& (!Number.isSafeInteger(options.maxBodyBytes)
|
|
@@ -366,7 +409,10 @@ export function registerWebhookHandler(
|
|
|
366
409
|
handler,
|
|
367
410
|
queue: options?.queue,
|
|
368
411
|
readSessionIdHint: options?.readSessionIdHint,
|
|
412
|
+
readPaymentIdHint: options?.readPaymentIdHint,
|
|
369
413
|
maxBodyBytes: options?.maxBodyBytes,
|
|
414
|
+
rawBody: options?.rawBody,
|
|
415
|
+
formatResponse: options?.formatResponse,
|
|
370
416
|
})
|
|
371
417
|
return () => {
|
|
372
418
|
webhookHandlerRegistry.delete(providerKey)
|
package/src/modules/registry.ts
CHANGED
|
@@ -178,6 +178,11 @@ export type ApiRouteManifestEntry = {
|
|
|
178
178
|
export type ModuleCli = {
|
|
179
179
|
command: string
|
|
180
180
|
run: (argv: string[]) => Promise<void> | void
|
|
181
|
+
// Usage text printed by the `mercato` dispatcher when the command is invoked with --help/-h.
|
|
182
|
+
help?: string
|
|
183
|
+
// Set when the command parses --help/-h itself; the dispatcher then forwards the flag
|
|
184
|
+
// instead of intercepting it, so the command can print its own richer usage text.
|
|
185
|
+
handlesHelp?: boolean
|
|
181
186
|
}
|
|
182
187
|
|
|
183
188
|
export type ModuleSubscriber = {
|