@open-mercato/shared 0.6.6 → 0.6.7-develop.6553.1.2ec7246e04

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.
@@ -510,6 +510,14 @@ function json(data: any, init?: ResponseInit) {
510
510
  })
511
511
  }
512
512
 
513
+ // Name of the selected-organization cookie (mirrors the directory module's
514
+ // OrganizationSwitcher, which writes `om_selected_org=...; path=/; samesite=lax`).
515
+ // Kept as a local literal so shared has no import dependency on a domain package.
516
+ const SELECTED_ORG_COOKIE = 'om_selected_org'
517
+ // Set-Cookie value that expires the stale selection so the next request falls
518
+ // back to the caller's home org. Attributes mirror how the switcher sets it.
519
+ const CLEAR_SELECTED_ORG_COOKIE = `${SELECTED_ORG_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`
520
+
513
521
  function attachOperationHeader(res: Response, logEntry: any) {
514
522
  if (!res || !(res instanceof Response)) return res
515
523
  if (!logEntry || typeof logEntry !== 'object') return res
@@ -1339,6 +1347,42 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1339
1347
  return { container, auth: scopedAuth, organizationScope: scope, selectedOrganizationId, organizationIds, request }
1340
1348
  }
1341
1349
 
1350
+ // The caller explicitly selected an organization (selected-org cookie) that no
1351
+ // longer resolves to a real, accessible org — e.g. a stale cookie after a DB
1352
+ // reset, or after the caller lost access to that org. Rather than silently
1353
+ // acting against a fallback org the caller did not select (writes) or showing
1354
+ // a different org's data than the one selected (reads), fail loud on every
1355
+ // org-scoped record operation so the client re-selects. Recovery surfaces
1356
+ // (org switcher, nav, profile) are custom routes, not this factory, so they
1357
+ // keep working. Returns a 422 Response when rejected, otherwise null.
1358
+ function rejectInvalidOrgSelection(ctx: CrudCtx, action: 'list' | 'create' | 'update' | 'delete'): Response | null {
1359
+ if (!ormCfg.orgField) return null
1360
+ if (!(ctx.organizationScope as OrganizationScope | null | undefined)?.selectionRejected) return null
1361
+ logForbidden({
1362
+ resourceKind,
1363
+ action,
1364
+ reason: 'organization_selection_invalid',
1365
+ userId: ctx.auth?.sub ?? null,
1366
+ tenantId: ctx.auth?.tenantId ?? null,
1367
+ organizationIds: ctx.organizationIds,
1368
+ })
1369
+ // Self-heal reads: expire the stale selected-org cookie so the caller's next
1370
+ // request falls back to their home org and the session recovers on its own
1371
+ // (important for single-org users, who have no org switcher to re-select
1372
+ // from). Writes intentionally do NOT clear it — a mutation must go through an
1373
+ // explicit, valid re-selection, never silently target a fallback org.
1374
+ const headers: Record<string, string> = action === 'list'
1375
+ ? { 'set-cookie': CLEAR_SELECTED_ORG_COOKIE }
1376
+ : {}
1377
+ return json(
1378
+ {
1379
+ error: 'Your selected organization is no longer available. Please re-select an organization and try again.',
1380
+ code: 'organization_selection_invalid',
1381
+ },
1382
+ { status: 422, headers },
1383
+ )
1384
+ }
1385
+
1342
1386
  async function GET(request: Request) {
1343
1387
  const profiler = createCrudProfiler(resourceKind, 'list')
1344
1388
  const requestMeta: Record<string, unknown> = { method: request.method }
@@ -1366,6 +1410,11 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1366
1410
  finishProfile({ reason: 'unauthorized' })
1367
1411
  return json({ error: 'Unauthorized' }, { status: 401 })
1368
1412
  }
1413
+ const listSelectionRejected = rejectInvalidOrgSelection(ctx, 'list')
1414
+ if (listSelectionRejected) {
1415
+ finishProfile({ reason: 'organization_selection_invalid' })
1416
+ return listSelectionRejected
1417
+ }
1369
1418
  if (!opts.list) {
1370
1419
  finishProfile({ reason: 'list_not_configured' })
1371
1420
  return json({ error: 'Not implemented' }, { status: 501 })
@@ -2032,6 +2081,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2032
2081
  if (!opts.create && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
2033
2082
  const ctx = await withCtx(request)
2034
2083
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2084
+ const createSelectionRejected = rejectInvalidOrgSelection(ctx, 'create')
2085
+ if (createSelectionRejected) return createSelectionRejected
2035
2086
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2036
2087
  logForbidden({
2037
2088
  resourceKind,
@@ -2302,6 +2353,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2302
2353
  if (!opts.update && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
2303
2354
  const ctx = await withCtx(request)
2304
2355
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2356
+ const updateSelectionRejected = rejectInvalidOrgSelection(ctx, 'update')
2357
+ if (updateSelectionRejected) return updateSelectionRejected
2305
2358
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2306
2359
  logForbidden({
2307
2360
  resourceKind,
@@ -2631,6 +2684,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2631
2684
  try {
2632
2685
  const ctx = await withCtx(request)
2633
2686
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2687
+ const deleteSelectionRejected = rejectInvalidOrgSelection(ctx, 'delete')
2688
+ if (deleteSelectionRejected) return deleteSelectionRejected
2634
2689
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2635
2690
  logForbidden({
2636
2691
  resourceKind,
@@ -0,0 +1,82 @@
1
+ import {
2
+ COUNTRY_PRIORITY,
3
+ ISO_COUNTRIES,
4
+ buildCountryOptions,
5
+ resolveCountryName,
6
+ } from '../countries'
7
+
8
+ describe('ISO_COUNTRIES', () => {
9
+ it('includes Kosovo, which the language-subtag registry does not list', () => {
10
+ const kosovo = ISO_COUNTRIES.filter((entry) => entry.code === 'XK')
11
+ expect(kosovo).toEqual([{ code: 'XK', name: 'Kosovo' }])
12
+ })
13
+
14
+ it('does not duplicate any country code', () => {
15
+ const codes = ISO_COUNTRIES.map((entry) => entry.code)
16
+ expect(codes).toHaveLength(new Set(codes).size)
17
+ })
18
+
19
+ it('keeps registry-derived countries intact', () => {
20
+ expect(ISO_COUNTRIES).toEqual(
21
+ expect.arrayContaining([
22
+ { code: 'PL', name: 'Poland' },
23
+ { code: 'DE', name: 'Germany' },
24
+ ])
25
+ )
26
+ })
27
+
28
+ it('stays sorted by name', () => {
29
+ const names = ISO_COUNTRIES.map((entry) => entry.name)
30
+ const sorted = [...names].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
31
+ expect(names).toEqual(sorted)
32
+ })
33
+ })
34
+
35
+ describe('resolveCountryName', () => {
36
+ it('resolves Kosovo for XK', () => {
37
+ expect(resolveCountryName('XK')).toBe('Kosovo')
38
+ })
39
+
40
+ it('resolves Kosovo for a lowercase xk', () => {
41
+ expect(resolveCountryName('xk')).toBe('Kosovo')
42
+ })
43
+
44
+ it('still resolves registry-backed countries', () => {
45
+ expect(resolveCountryName('PL')).toBe('Poland')
46
+ })
47
+
48
+ it('falls back to the raw code for an unknown region', () => {
49
+ expect(resolveCountryName('ZZZ')).toBe('ZZZ')
50
+ })
51
+ })
52
+
53
+ describe('buildCountryOptions', () => {
54
+ it('offers Kosovo as a selectable option', () => {
55
+ const options = buildCountryOptions()
56
+ expect(options).toEqual(expect.arrayContaining([{ code: 'XK', label: 'Kosovo' }]))
57
+ })
58
+
59
+ it('offers exactly one Kosovo option', () => {
60
+ const options = buildCountryOptions().filter((option) => option.code === 'XK')
61
+ expect(options).toHaveLength(1)
62
+ })
63
+
64
+ it('lists Kosovo after the prioritized countries', () => {
65
+ const options = buildCountryOptions()
66
+ const kosovoIndex = options.findIndex((option) => option.code === 'XK')
67
+ expect(kosovoIndex).toBeGreaterThanOrEqual(COUNTRY_PRIORITY.length)
68
+ })
69
+
70
+ it('keeps the prioritized countries at the top', () => {
71
+ const options = buildCountryOptions()
72
+ const leading = options.slice(0, COUNTRY_PRIORITY.length).map((option) => option.code)
73
+ expect(leading.slice().sort()).toEqual(COUNTRY_PRIORITY.slice().sort())
74
+ })
75
+
76
+ it('applies transformLabel to the supplemental country too', () => {
77
+ const options = buildCountryOptions({
78
+ transformLabel: (code, defaultLabel) => `${defaultLabel} (${code})`,
79
+ })
80
+ expect(options).toEqual(expect.arrayContaining([{ code: 'XK', label: 'Kosovo (XK)' }]))
81
+ })
82
+ })
@@ -28,9 +28,18 @@ const RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])
28
28
  name: entry.Description.join(', '),
29
29
  }))
30
30
 
31
- export const ISO_COUNTRIES: IsoCountry[] = [...RAW_COUNTRIES].sort((a, b) =>
32
- a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
33
- )
31
+ // Codes in common use that the language-subtag registry does not list as region subtags.
32
+ // XK is user-assigned rather than ISO 3166-1 assigned, so it never appears in the registry.
33
+ const SUPPLEMENTAL_COUNTRIES: IsoCountry[] = [
34
+ { code: 'XK', name: 'Kosovo' },
35
+ ]
36
+
37
+ const RAW_COUNTRY_CODES = new Set(RAW_COUNTRIES.map((entry) => entry.code))
38
+
39
+ export const ISO_COUNTRIES: IsoCountry[] = [
40
+ ...RAW_COUNTRIES,
41
+ ...SUPPLEMENTAL_COUNTRIES.filter((entry) => !RAW_COUNTRY_CODES.has(entry.code)),
42
+ ].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
34
43
 
35
44
  export const COUNTRY_PRIORITY: string[] = ['PL', 'DE', 'ES', 'FR', 'IT', 'US', 'GB', 'CA']
36
45
 
@@ -58,7 +67,9 @@ export function resolveCountryName(code: string, options: { locale?: string } =
58
67
  if (!displayNames) return fallback
59
68
  try {
60
69
  const label = displayNames.of(normalized as any)
61
- return typeof label === 'string' ? label : fallback
70
+ // Intl echoes the raw code back when its ICU data cannot resolve the region.
71
+ if (typeof label !== 'string' || label === normalized) return fallback
72
+ return label
62
73
  } catch {
63
74
  return fallback
64
75
  }
@@ -80,12 +80,39 @@ export function isPrivateIPv6(address: string): boolean {
80
80
  if (isIPv4CompatibleIPv6(segments)) return true
81
81
 
82
82
  const embedded = embeddedIPv4FromIPv6(segments)
83
- if (embedded && isPrivateIPv4(embedded)) return true
83
+ if (embedded) return isPrivateIPv4(embedded)
84
84
 
85
85
  const [a] = segments
86
86
  if ((a & 0xfe00) === 0xfc00) return true
87
87
  if ((a & 0xffc0) === 0xfe80) return true
88
88
  if ((a & 0xff00) === 0xff00) return true
89
+ if (
90
+ a === 0x0100 &&
91
+ segments[1] === 0 &&
92
+ segments[2] === 0 &&
93
+ segments[3] === 0
94
+ ) {
95
+ return true
96
+ }
97
+ if (a === 0x2001 && (segments[1] & 0xfe00) === 0) {
98
+ return !isGloballyReachableIetfProtocolAssignment(segments)
99
+ }
100
+ if (a === 0x2001 && segments[1] === 0x0db8) return true
101
+ if ((a & 0xfff0) === 0x3ff0) return true
102
+ // Except for the explicitly handled IPv4 translation ranges above, globally
103
+ // routable unicast IPv6 addresses are allocated from 2000::/3.
104
+ if ((a & 0xe000) !== 0x2000) return true
105
+ return false
106
+ }
107
+
108
+ function isGloballyReachableIetfProtocolAssignment(segments: number[]): boolean {
109
+ const [, b, c, d, e, f, g, h] = segments
110
+ const trailingZero = c === 0 && d === 0 && e === 0 && f === 0 && g === 0
111
+ if (b === 0x0001 && trailingZero && h >= 1 && h <= 3) return true
112
+ if (b === 0x0003) return true
113
+ if (b === 0x0004 && c === 0x0112) return true
114
+ if ((b & 0xfff0) === 0x0020) return true
115
+ if ((b & 0xfff0) === 0x0030) return true
89
116
  return false
90
117
  }
91
118
 
package/src/lib/string.ts CHANGED
@@ -6,3 +6,11 @@ export function trimToUndefined(value: unknown): string | undefined {
6
6
  return trimmed.length > 0 ? trimmed : undefined
7
7
  }
8
8
 
9
+ export function parseCommaSeparatedList(value: string | null | undefined): string[] {
10
+ if (typeof value !== 'string') return []
11
+ return value
12
+ .split(',')
13
+ .map((entry) => entry.trim())
14
+ .filter(Boolean)
15
+ }
16
+
@@ -1,6 +1,6 @@
1
1
  import { lookup } from 'node:dns/promises'
2
2
  import { isIP } from 'node:net'
3
- import { Agent, type Dispatcher } from 'undici'
3
+ import { Agent, fetch as undiciFetch, type Dispatcher } from 'undici'
4
4
  import { isBlockedHostname, isPrivateIpAddress } from './network'
5
5
 
6
6
  export type UrlSafetyReason =
@@ -102,6 +102,8 @@ export function assertStaticallySafeOutboundUrl(
102
102
 
103
103
  export type AssertSafeOutboundUrlOptions = AssertStaticUrlOptions & {
104
104
  lookupHost?: HostLookup
105
+ /** Resolve and return DNS answers even when private addresses are explicitly allowed. */
106
+ pinAllowedPrivate?: boolean
105
107
  }
106
108
 
107
109
  export async function assertSafeOutboundUrl(
@@ -118,7 +120,8 @@ export type ResolveSafeOutboundUrlResult = {
118
120
  hostname: string
119
121
  /**
120
122
  * The validated DNS records, in lookup order. `null` when the hostname is an IP literal
121
- * (no DNS lookup performed) or when `allowPrivate` short-circuited validation.
123
+ * (no DNS lookup performed) or when `allowPrivate` short-circuited validation without
124
+ * `pinAllowedPrivate`.
122
125
  */
123
126
  addresses: ReadonlyArray<ResolvedHostAddress> | null
124
127
  }
@@ -136,14 +139,16 @@ export async function resolveSafeOutboundUrl(
136
139
  const subject = options.subject ?? 'URL'
137
140
  const factory = options.errorFactory ?? defaultErrorFactory
138
141
  const { url, hostname } = parseOutboundUrl(rawUrl, options)
139
- if (options.allowPrivate) return { url, hostname, addresses: null }
142
+ if (options.allowPrivate && !options.pinAllowedPrivate) {
143
+ return { url, hostname, addresses: null }
144
+ }
140
145
 
141
- if (isBlockedHostname(hostname)) {
146
+ if (!options.allowPrivate && isBlockedHostname(hostname)) {
142
147
  throw factory('blocked_hostname', `${subject} host "${hostname}" is not allowed`)
143
148
  }
144
149
 
145
150
  if (isIP(hostname)) {
146
- if (isPrivateIpAddress(hostname)) {
151
+ if (!options.allowPrivate && isPrivateIpAddress(hostname)) {
147
152
  throw factory(
148
153
  'private_ip_literal',
149
154
  `${subject} host "${hostname}" resolves to a private or reserved IP range`,
@@ -179,7 +184,7 @@ export async function resolveSafeOutboundUrl(
179
184
  }
180
185
 
181
186
  for (const record of addresses) {
182
- if (isPrivateIpAddress(record.address)) {
187
+ if (!options.allowPrivate && isPrivateIpAddress(record.address)) {
183
188
  throw factory(
184
189
  'private_ip_resolved',
185
190
  `${subject} host "${hostname}" resolves to a private or reserved IP address (${record.address})`,
@@ -197,16 +202,23 @@ export type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {
197
202
  * not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.
198
203
  */
199
204
  fetchImpl?: typeof fetch
205
+ /** Test seam for the dispatcher-compatible fetch used by DNS-pinned requests. */
206
+ pinnedFetchImpl?: PinnedFetch
200
207
  }
201
208
 
209
+ export type PinnedFetch = (
210
+ rawUrl: string,
211
+ init: RequestInit & { dispatcher: Dispatcher },
212
+ ) => Promise<Response>
213
+
202
214
  /**
203
215
  * Validates an outbound URL and performs `fetch()` with the connection pinned to a
204
216
  * pre-validated IP address, so DNS cannot be re-resolved between validation and connect
205
217
  * (DNS rebinding). Always defaults to `redirect: 'manual'` — callers MUST decide what to
206
218
  * do with 3xx responses (re-validate the redirect target before following).
207
219
  *
208
- * For IP literal hosts and `allowPrivate=true`, no DNS pinning is performed because there
209
- * is no DNS lookup to defeat.
220
+ * For IP literal hosts no DNS pinning is performed because there is no DNS lookup to defeat.
221
+ * Callers that allow private hostnames can set `pinAllowedPrivate` to retain DNS pinning.
210
222
  */
211
223
  export async function safeOutboundFetch(
212
224
  rawUrl: string,
@@ -237,9 +249,8 @@ export async function safeOutboundFetch(
237
249
  })
238
250
 
239
251
  try {
240
- return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher } as RequestInit & {
241
- dispatcher: Dispatcher
242
- })
252
+ const pinnedFetch = options.pinnedFetchImpl ?? (undiciFetch as unknown as PinnedFetch)
253
+ return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher })
243
254
  } finally {
244
255
  dispatcher.close().catch(() => {})
245
256
  }
@@ -247,8 +258,12 @@ export async function safeOutboundFetch(
247
258
 
248
259
  export type PinnedDnsLookup = (
249
260
  host: string,
250
- opts: unknown,
251
- cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
261
+ opts: { all?: boolean },
262
+ cb: (
263
+ err: NodeJS.ErrnoException | null,
264
+ address: string | ResolvedHostAddress[],
265
+ family?: number,
266
+ ) => void,
252
267
  ) => void
253
268
 
254
269
  /**
@@ -261,7 +276,7 @@ export function createPinnedDnsLookup(
261
276
  expectedHostname: string,
262
277
  pinned: ResolvedHostAddress,
263
278
  ): PinnedDnsLookup {
264
- return (host, _opts, cb) => {
279
+ return (host, opts, cb) => {
265
280
  if (host !== expectedHostname) {
266
281
  const err: NodeJS.ErrnoException = new Error(
267
282
  `Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`,
@@ -270,6 +285,10 @@ export function createPinnedDnsLookup(
270
285
  cb(err, '', 0)
271
286
  return
272
287
  }
288
+ if (opts.all) {
289
+ cb(null, [pinned])
290
+ return
291
+ }
273
292
  cb(null, pinned.address, pinned.family)
274
293
  }
275
294
  }
@@ -117,6 +117,8 @@ export type PaymentGatewayClientSession =
117
117
  export interface CreateSessionInput {
118
118
  orderId?: string
119
119
  paymentId: string
120
+ /** Stable operation key for provider-native session creation idempotency. */
121
+ idempotencyKey?: string
120
122
  tenantId: string
121
123
  organizationId: string
122
124
  amount: number