@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.
- package/AGENTS.md +18 -0
- package/dist/lib/crud/factory.js +33 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/location/countries.js +10 -4
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/network.js +20 -1
- package/dist/lib/network.js.map +2 -2
- package/dist/lib/string.js +5 -0
- package/dist/lib/string.js.map +2 -2
- package/dist/lib/url-safety.js +14 -7
- package/dist/lib/url-safety.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/package.json +4 -3
- package/src/lib/__tests__/string.test.ts +49 -0
- package/src/lib/__tests__/url-safety.test.ts +28 -0
- package/src/lib/crud/factory.ts +55 -0
- package/src/lib/location/__tests__/countries.test.ts +82 -0
- package/src/lib/location/countries.ts +15 -4
- package/src/lib/network.ts +28 -1
- package/src/lib/string.ts +8 -0
- package/src/lib/url-safety.ts +33 -14
- package/src/modules/payment_gateways/types.ts +2 -0
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/network.ts
CHANGED
|
@@ -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
|
|
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
|
+
|
package/src/lib/url-safety.ts
CHANGED
|
@@ -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)
|
|
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
|
|
209
|
-
*
|
|
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
|
-
|
|
241
|
-
|
|
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:
|
|
251
|
-
cb: (
|
|
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,
|
|
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
|