@open-mercato/shared 0.10.1-develop.7576.1.21d5b50435 → 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.
Files changed (125) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -4
  3. package/dist/lib/auth/server.js +1 -1
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/bootstrap/dynamicLoader.js +46 -6
  6. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  7. package/dist/lib/bootstrap/factory.js +4 -3
  8. package/dist/lib/bootstrap/factory.js.map +2 -2
  9. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
  10. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
  11. package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
  12. package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
  13. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
  14. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
  15. package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
  16. package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
  17. package/dist/lib/catalog-visibility/index.js +4 -0
  18. package/dist/lib/catalog-visibility/index.js.map +2 -2
  19. package/dist/lib/commands/command-bus.js +45 -13
  20. package/dist/lib/commands/command-bus.js.map +2 -2
  21. package/dist/lib/commands/types.js.map +2 -2
  22. package/dist/lib/crud/exporters.js +1 -1
  23. package/dist/lib/crud/exporters.js.map +2 -2
  24. package/dist/lib/crud/ids.js +13 -1
  25. package/dist/lib/crud/ids.js.map +2 -2
  26. package/dist/lib/db/advisoryLock.js +172 -0
  27. package/dist/lib/db/advisoryLock.js.map +7 -0
  28. package/dist/lib/db/pg-errors.js +14 -0
  29. package/dist/lib/db/pg-errors.js.map +2 -2
  30. package/dist/lib/localization/index.js +9 -1
  31. package/dist/lib/localization/index.js.map +2 -2
  32. package/dist/lib/localization/translatable-fields.js +26 -0
  33. package/dist/lib/localization/translatable-fields.js.map +2 -2
  34. package/dist/lib/number.js +6 -0
  35. package/dist/lib/number.js.map +2 -2
  36. package/dist/lib/openapi/generator.js +84 -67
  37. package/dist/lib/openapi/generator.js.map +2 -2
  38. package/dist/lib/query/engine.js +45 -10
  39. package/dist/lib/query/engine.js.map +2 -2
  40. package/dist/lib/query/join-utils.js +1 -1
  41. package/dist/lib/query/join-utils.js.map +2 -2
  42. package/dist/lib/query/overlap.js +58 -0
  43. package/dist/lib/query/overlap.js.map +7 -0
  44. package/dist/lib/query/types.js.map +2 -2
  45. package/dist/lib/url.js +24 -0
  46. package/dist/lib/url.js.map +2 -2
  47. package/dist/lib/version.js +1 -1
  48. package/dist/lib/version.js.map +1 -1
  49. package/dist/lib/webhooks/body.js +7 -3
  50. package/dist/lib/webhooks/body.js.map +2 -2
  51. package/dist/lib/webhooks/index.js +2 -0
  52. package/dist/lib/webhooks/index.js.map +2 -2
  53. package/dist/modules/integrations/types.js.map +2 -2
  54. package/dist/modules/notifications/safeHref.js +20 -0
  55. package/dist/modules/notifications/safeHref.js.map +7 -0
  56. package/dist/modules/notifications/types.js +4 -0
  57. package/dist/modules/notifications/types.js.map +3 -3
  58. package/dist/modules/payment_gateways/types.js +14 -1
  59. package/dist/modules/payment_gateways/types.js.map +2 -2
  60. package/dist/modules/registry.js.map +2 -2
  61. package/dist/modules/search.js.map +2 -2
  62. package/dist/modules/widgets/injection-loader.js +14 -3
  63. package/dist/modules/widgets/injection-loader.js.map +2 -2
  64. package/package.json +9 -2
  65. package/src/lib/__tests__/number.test.ts +24 -1
  66. package/src/lib/__tests__/url.cross-site.test.ts +140 -0
  67. package/src/lib/auth/__tests__/server.test.ts +14 -0
  68. package/src/lib/auth/server.ts +1 -1
  69. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
  70. package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
  71. package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
  72. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
  73. package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
  74. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
  75. package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
  76. package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
  77. package/src/lib/bootstrap/dynamicLoader.ts +63 -5
  78. package/src/lib/bootstrap/factory.ts +8 -3
  79. package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
  80. package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
  81. package/src/lib/bootstrap/types.ts +5 -1
  82. package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
  83. package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
  84. package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
  85. package/src/lib/catalog-visibility/index.ts +2 -0
  86. package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
  87. package/src/lib/commands/command-bus.ts +42 -10
  88. package/src/lib/commands/command-interceptor.ts +4 -0
  89. package/src/lib/commands/types.ts +14 -0
  90. package/src/lib/crud/__tests__/exporters.test.ts +18 -0
  91. package/src/lib/crud/__tests__/ids.test.ts +34 -1
  92. package/src/lib/crud/exporters.ts +1 -1
  93. package/src/lib/crud/ids.ts +24 -0
  94. package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
  95. package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
  96. package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
  97. package/src/lib/db/advisoryLock.ts +271 -0
  98. package/src/lib/db/pg-errors.ts +35 -0
  99. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
  100. package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
  101. package/src/lib/localization/index.ts +8 -1
  102. package/src/lib/localization/translatable-fields.ts +46 -0
  103. package/src/lib/number.ts +13 -0
  104. package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
  105. package/src/lib/openapi/generator.ts +106 -77
  106. package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
  107. package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
  108. package/src/lib/query/__tests__/overlap.test.ts +219 -0
  109. package/src/lib/query/engine.ts +58 -8
  110. package/src/lib/query/join-utils.ts +1 -1
  111. package/src/lib/query/overlap.ts +106 -0
  112. package/src/lib/query/types.ts +13 -1
  113. package/src/lib/url.ts +30 -0
  114. package/src/lib/webhooks/__tests__/body.test.ts +48 -0
  115. package/src/lib/webhooks/body.ts +11 -4
  116. package/src/lib/webhooks/index.ts +1 -0
  117. package/src/modules/integrations/types.ts +6 -0
  118. package/src/modules/notifications/safeHref.ts +19 -0
  119. package/src/modules/notifications/types.ts +7 -0
  120. package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
  121. package/src/modules/payment_gateways/types.ts +52 -6
  122. package/src/modules/registry.ts +5 -0
  123. package/src/modules/search.ts +39 -0
  124. package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
  125. package/src/modules/widgets/injection-loader.ts +33 -3
@@ -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 = this.applyColumnOp(q, expr, f.op, f.value)
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
- return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
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
- return builder.where(column as any, 'in', Array.isArray(value) ? value : [value])
1594
- case 'nin':
1595
- return builder.where(column as any, 'not in', Array.isArray(value) ? value : [value])
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': return eb(column, 'in', Array.isArray(value) ? value : [value])
1755
- case 'nin': return eb(column, 'not in', Array.isArray(value) ? value : [value])
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
+ }
@@ -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
- export type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists'
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)
@@ -23,10 +23,10 @@ export function resolveWebhookBodyLimitBytes(
23
23
  return Number.isSafeInteger(parsed) ? parsed : fallback
24
24
  }
25
25
 
26
- export async function readBoundedRequestBody(
26
+ export async function readBoundedRequestBytes(
27
27
  request: Request,
28
28
  options?: { maxBytes?: number },
29
- ): Promise<string> {
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 new TextDecoder().decode(body)
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
  }
@@ -4,6 +4,7 @@ export { generateWebhookSecret, parseWebhookSecret, isValidWebhookSecret } from
4
4
  export {
5
5
  DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,
6
6
  readBoundedRequestBody,
7
+ readBoundedRequestBytes,
7
8
  resolveWebhookBodyLimitBytes,
8
9
  WebhookBodyTooLargeError,
9
10
  } from './body'
@@ -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)
@@ -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 = {