@open-mercato/shared 0.6.6-develop.6551.1.1511bfa890 → 0.6.6

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,14 +510,6 @@ 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
-
521
513
  function attachOperationHeader(res: Response, logEntry: any) {
522
514
  if (!res || !(res instanceof Response)) return res
523
515
  if (!logEntry || typeof logEntry !== 'object') return res
@@ -1347,42 +1339,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1347
1339
  return { container, auth: scopedAuth, organizationScope: scope, selectedOrganizationId, organizationIds, request }
1348
1340
  }
1349
1341
 
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
-
1386
1342
  async function GET(request: Request) {
1387
1343
  const profiler = createCrudProfiler(resourceKind, 'list')
1388
1344
  const requestMeta: Record<string, unknown> = { method: request.method }
@@ -1410,11 +1366,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1410
1366
  finishProfile({ reason: 'unauthorized' })
1411
1367
  return json({ error: 'Unauthorized' }, { status: 401 })
1412
1368
  }
1413
- const listSelectionRejected = rejectInvalidOrgSelection(ctx, 'list')
1414
- if (listSelectionRejected) {
1415
- finishProfile({ reason: 'organization_selection_invalid' })
1416
- return listSelectionRejected
1417
- }
1418
1369
  if (!opts.list) {
1419
1370
  finishProfile({ reason: 'list_not_configured' })
1420
1371
  return json({ error: 'Not implemented' }, { status: 501 })
@@ -2081,8 +2032,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2081
2032
  if (!opts.create && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
2082
2033
  const ctx = await withCtx(request)
2083
2034
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2084
- const createSelectionRejected = rejectInvalidOrgSelection(ctx, 'create')
2085
- if (createSelectionRejected) return createSelectionRejected
2086
2035
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2087
2036
  logForbidden({
2088
2037
  resourceKind,
@@ -2353,8 +2302,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2353
2302
  if (!opts.update && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
2354
2303
  const ctx = await withCtx(request)
2355
2304
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2356
- const updateSelectionRejected = rejectInvalidOrgSelection(ctx, 'update')
2357
- if (updateSelectionRejected) return updateSelectionRejected
2358
2305
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2359
2306
  logForbidden({
2360
2307
  resourceKind,
@@ -2684,8 +2631,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2684
2631
  try {
2685
2632
  const ctx = await withCtx(request)
2686
2633
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2687
- const deleteSelectionRejected = rejectInvalidOrgSelection(ctx, 'delete')
2688
- if (deleteSelectionRejected) return deleteSelectionRejected
2689
2634
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2690
2635
  logForbidden({
2691
2636
  resourceKind,
@@ -28,18 +28,9 @@ const RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])
28
28
  name: entry.Description.join(', '),
29
29
  }))
30
30
 
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' }))
31
+ export const ISO_COUNTRIES: IsoCountry[] = [...RAW_COUNTRIES].sort((a, b) =>
32
+ a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
33
+ )
43
34
 
44
35
  export const COUNTRY_PRIORITY: string[] = ['PL', 'DE', 'ES', 'FR', 'IT', 'US', 'GB', 'CA']
45
36
 
@@ -67,9 +58,7 @@ export function resolveCountryName(code: string, options: { locale?: string } =
67
58
  if (!displayNames) return fallback
68
59
  try {
69
60
  const label = displayNames.of(normalized as any)
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
61
+ return typeof label === 'string' ? label : fallback
73
62
  } catch {
74
63
  return fallback
75
64
  }
@@ -80,39 +80,12 @@ export function isPrivateIPv6(address: string): boolean {
80
80
  if (isIPv4CompatibleIPv6(segments)) return true
81
81
 
82
82
  const embedded = embeddedIPv4FromIPv6(segments)
83
- if (embedded) return isPrivateIPv4(embedded)
83
+ if (embedded && isPrivateIPv4(embedded)) return true
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
116
89
  return false
117
90
  }
118
91
 
package/src/lib/string.ts CHANGED
@@ -6,11 +6,3 @@ 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, fetch as undiciFetch, type Dispatcher } from 'undici'
3
+ import { Agent, type Dispatcher } from 'undici'
4
4
  import { isBlockedHostname, isPrivateIpAddress } from './network'
5
5
 
6
6
  export type UrlSafetyReason =
@@ -102,8 +102,6 @@ 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
107
105
  }
108
106
 
109
107
  export async function assertSafeOutboundUrl(
@@ -120,8 +118,7 @@ export type ResolveSafeOutboundUrlResult = {
120
118
  hostname: string
121
119
  /**
122
120
  * The validated DNS records, in lookup order. `null` when the hostname is an IP literal
123
- * (no DNS lookup performed) or when `allowPrivate` short-circuited validation without
124
- * `pinAllowedPrivate`.
121
+ * (no DNS lookup performed) or when `allowPrivate` short-circuited validation.
125
122
  */
126
123
  addresses: ReadonlyArray<ResolvedHostAddress> | null
127
124
  }
@@ -139,16 +136,14 @@ export async function resolveSafeOutboundUrl(
139
136
  const subject = options.subject ?? 'URL'
140
137
  const factory = options.errorFactory ?? defaultErrorFactory
141
138
  const { url, hostname } = parseOutboundUrl(rawUrl, options)
142
- if (options.allowPrivate && !options.pinAllowedPrivate) {
143
- return { url, hostname, addresses: null }
144
- }
139
+ if (options.allowPrivate) return { url, hostname, addresses: null }
145
140
 
146
- if (!options.allowPrivate && isBlockedHostname(hostname)) {
141
+ if (isBlockedHostname(hostname)) {
147
142
  throw factory('blocked_hostname', `${subject} host "${hostname}" is not allowed`)
148
143
  }
149
144
 
150
145
  if (isIP(hostname)) {
151
- if (!options.allowPrivate && isPrivateIpAddress(hostname)) {
146
+ if (isPrivateIpAddress(hostname)) {
152
147
  throw factory(
153
148
  'private_ip_literal',
154
149
  `${subject} host "${hostname}" resolves to a private or reserved IP range`,
@@ -184,7 +179,7 @@ export async function resolveSafeOutboundUrl(
184
179
  }
185
180
 
186
181
  for (const record of addresses) {
187
- if (!options.allowPrivate && isPrivateIpAddress(record.address)) {
182
+ if (isPrivateIpAddress(record.address)) {
188
183
  throw factory(
189
184
  'private_ip_resolved',
190
185
  `${subject} host "${hostname}" resolves to a private or reserved IP address (${record.address})`,
@@ -202,23 +197,16 @@ export type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {
202
197
  * not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.
203
198
  */
204
199
  fetchImpl?: typeof fetch
205
- /** Test seam for the dispatcher-compatible fetch used by DNS-pinned requests. */
206
- pinnedFetchImpl?: PinnedFetch
207
200
  }
208
201
 
209
- export type PinnedFetch = (
210
- rawUrl: string,
211
- init: RequestInit & { dispatcher: Dispatcher },
212
- ) => Promise<Response>
213
-
214
202
  /**
215
203
  * Validates an outbound URL and performs `fetch()` with the connection pinned to a
216
204
  * pre-validated IP address, so DNS cannot be re-resolved between validation and connect
217
205
  * (DNS rebinding). Always defaults to `redirect: 'manual'` — callers MUST decide what to
218
206
  * do with 3xx responses (re-validate the redirect target before following).
219
207
  *
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.
208
+ * For IP literal hosts and `allowPrivate=true`, no DNS pinning is performed because there
209
+ * is no DNS lookup to defeat.
222
210
  */
223
211
  export async function safeOutboundFetch(
224
212
  rawUrl: string,
@@ -249,8 +237,9 @@ export async function safeOutboundFetch(
249
237
  })
250
238
 
251
239
  try {
252
- const pinnedFetch = options.pinnedFetchImpl ?? (undiciFetch as unknown as PinnedFetch)
253
- return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher })
240
+ return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher } as RequestInit & {
241
+ dispatcher: Dispatcher
242
+ })
254
243
  } finally {
255
244
  dispatcher.close().catch(() => {})
256
245
  }
@@ -258,12 +247,8 @@ export async function safeOutboundFetch(
258
247
 
259
248
  export type PinnedDnsLookup = (
260
249
  host: string,
261
- opts: { all?: boolean },
262
- cb: (
263
- err: NodeJS.ErrnoException | null,
264
- address: string | ResolvedHostAddress[],
265
- family?: number,
266
- ) => void,
250
+ opts: unknown,
251
+ cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
267
252
  ) => void
268
253
 
269
254
  /**
@@ -276,7 +261,7 @@ export function createPinnedDnsLookup(
276
261
  expectedHostname: string,
277
262
  pinned: ResolvedHostAddress,
278
263
  ): PinnedDnsLookup {
279
- return (host, opts, cb) => {
264
+ return (host, _opts, cb) => {
280
265
  if (host !== expectedHostname) {
281
266
  const err: NodeJS.ErrnoException = new Error(
282
267
  `Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`,
@@ -285,10 +270,6 @@ export function createPinnedDnsLookup(
285
270
  cb(err, '', 0)
286
271
  return
287
272
  }
288
- if (opts.all) {
289
- cb(null, [pinned])
290
- return
291
- }
292
273
  cb(null, pinned.address, pinned.family)
293
274
  }
294
275
  }
@@ -117,8 +117,6 @@ 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
122
120
  tenantId: string
123
121
  organizationId: string
124
122
  amount: number
@@ -1,49 +0,0 @@
1
- import { parseCommaSeparatedList, trimToUndefined } from '../string'
2
-
3
- describe('parseCommaSeparatedList', () => {
4
- it('splits a comma-separated string into entries', () => {
5
- expect(parseCommaSeparatedList('low,medium,high')).toEqual(['low', 'medium', 'high'])
6
- })
7
-
8
- it('trims surrounding whitespace from every entry', () => {
9
- expect(parseCommaSeparatedList(' admin , editor,viewer ')).toEqual(['admin', 'editor', 'viewer'])
10
- })
11
-
12
- it('drops empty entries produced by repeated or trailing separators', () => {
13
- expect(parseCommaSeparatedList('a,,b,')).toEqual(['a', 'b'])
14
- expect(parseCommaSeparatedList(',a, ,b')).toEqual(['a', 'b'])
15
- })
16
-
17
- it('returns an empty array when every entry is blank', () => {
18
- expect(parseCommaSeparatedList(',,')).toEqual([])
19
- expect(parseCommaSeparatedList(' , ')).toEqual([])
20
- })
21
-
22
- it('returns an empty array for blank or non-string inputs', () => {
23
- expect(parseCommaSeparatedList('')).toEqual([])
24
- expect(parseCommaSeparatedList(' ')).toEqual([])
25
- expect(parseCommaSeparatedList(null)).toEqual([])
26
- expect(parseCommaSeparatedList(undefined)).toEqual([])
27
- })
28
-
29
- it('returns a single entry when the input has no separator', () => {
30
- expect(parseCommaSeparatedList('admin')).toEqual(['admin'])
31
- expect(parseCommaSeparatedList(' admin ')).toEqual(['admin'])
32
- })
33
-
34
- it('preserves interior whitespace within an entry', () => {
35
- expect(parseCommaSeparatedList('order created, order shipped')).toEqual(['order created', 'order shipped'])
36
- })
37
- })
38
-
39
- describe('trimToUndefined', () => {
40
- it('returns the trimmed value when it is a non-blank string', () => {
41
- expect(trimToUndefined(' value ')).toBe('value')
42
- })
43
-
44
- it('returns undefined for blank or non-string values', () => {
45
- expect(trimToUndefined(' ')).toBeUndefined()
46
- expect(trimToUndefined(null)).toBeUndefined()
47
- expect(trimToUndefined(42)).toBeUndefined()
48
- })
49
- })
@@ -1,82 +0,0 @@
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
- })