Skip to content
← Public packages

@kody/salesforce

Salesforce REST helpers for identity, SOQL, multi-org routing, and error investigation.

src/orgs.ts

303 lines · 9.0 KB · TypeScript
import { kody } from 'kody:runtime'
import { fetchUserInfo } from './request.ts'
import type { JsonRecord } from './types.ts'
import { requireRecord, requireString } from './types.ts'

export const ORG_MAP_STORAGE_KEY = 'org-map'

export type OrgMapEntry = {
	integrationName: string
	label?: string | null
	instanceHost?: string | null
}

export type OrgMap = Record<string, OrgMapEntry | string>

export type ListedOrg = {
	integrationName: string
	organizationId: string | null
	instanceHost: string | null
	userType: string | null
	ok: boolean
	error?: string
}

export type ListOrgsResult = {
	orgs: Array<ListedOrg>
	/** organizationId → integrationName (plus optional instance host). Persist this in a private package's packageStorage. */
	map: Record<string, OrgMapEntry>
}

export type ResolveOrgInput = {
	organizationId?: string
	integrationName?: string
	/** Caller-owned map, typically loaded from a private package's packageStorage. */
	orgMap?: OrgMap
	/** Webhook body, Flow email JSON, or any payload that may include an org id. */
	payload?: unknown
	error?: unknown
	body?: unknown
}

export type ResolveOrgResult = {
	organizationId: string | null
	integrationName: string
	source: 'explicit' | 'org-map' | 'live-discovery'
	instanceHost: string | null
}

const ORG_ID_PATTERN = /\b(00D[a-zA-Z0-9]{12}(?:[a-zA-Z0-9]{3})?)\b/g
const ORG_ID_KEYS = [
	'organizationId',
	'organization_id',
	'orgId',
	'org_id',
	'OrganizationId',
	'organizationID',
]

export function isSalesforceIntegrationName(name: string): boolean {
	return name === 'salesforce' || name.startsWith('salesforce-')
}

export function normalizeOrganizationId(value: string): string {
	const id = requireString(value, 'organizationId')
	if (!/^00D[a-zA-Z0-9]{12}([a-zA-Z0-9]{3})?$/.test(id)) {
		throw new Error('organizationId must be a Salesforce Organization id (00D…).')
	}
	return id
}

export function extractOrganizationId(...candidates: Array<unknown>): string | null {
	for (const candidate of candidates) {
		const found = findOrganizationId(candidate, 0, new Set())
		if (found) return found
	}
	return null
}

export function lookupOrgMap(
	orgMap: OrgMap | undefined,
	organizationId: string,
): OrgMapEntry | null {
	if (!orgMap) return null
	const direct = orgMap[organizationId]
	if (direct) return normalizeMapEntry(direct, organizationId)
	const compact = organizationId.slice(0, 15)
	const compactHit = orgMap[compact]
	if (compactHit) return normalizeMapEntry(compactHit, compact)
	for (const [key, value] of Object.entries(orgMap)) {
		if (key.slice(0, 15) === compact) return normalizeMapEntry(value, key)
	}
	return null
}

/**
 * List saved Salesforce connections and their organization ids.
 * Live `@kody/salesforce` cannot persist this map; a private wrapper should
 * store `map` in packageStorage under `org-map` and pass it to resolve-org.
 */
export async function listOrgs(): Promise<ListOrgsResult> {
	const names = await listSalesforceIntegrationNames()
	const orgs: Array<ListedOrg> = []
	const map: Record<string, OrgMapEntry> = {}

	for (const integrationName of names) {
		try {
			const user = await fetchUserInfo(integrationName)
			const organizationId =
				typeof user.organization_id === 'string' && user.organization_id.length > 0
					? user.organization_id
					: null
			const instanceHost = hostFromUserInfo(user.urls?.rest ?? user.urls?.custom_domain)
			orgs.push({
				integrationName,
				organizationId,
				instanceHost,
				userType: typeof user.user_type === 'string' ? user.user_type : null,
				ok: true,
			})
			if (organizationId) {
				map[organizationId] = { integrationName, instanceHost }
			}
		} catch (error) {
			orgs.push({
				integrationName,
				organizationId: null,
				instanceHost: null,
				userType: null,
				ok: false,
				error: error instanceof Error ? error.message : String(error),
			})
		}
	}

	return { orgs, map }
}

export async function resolveOrg(input: ResolveOrgInput): Promise<ResolveOrgResult> {
	const explicitName = optionalName(input.integrationName)
	const organizationId =
		(input.organizationId ? normalizeOrganizationId(input.organizationId) : null) ??
		extractOrganizationId(input.payload, input.error, input.body, input)

	if (explicitName) {
		return {
			organizationId,
			integrationName: explicitName,
			source: 'explicit',
			instanceHost: mappedHost(input.orgMap, organizationId),
		}
	}

	if (!organizationId) {
		throw new Error(
			'Cannot resolve a Salesforce org: pass integrationName, organizationId, or a payload that includes one (organizationId / 00D…). Do not default to "salesforce" when several orgs may be connected.',
		)
	}

	const mapped = lookupOrgMap(input.orgMap, organizationId)
	if (mapped) {
		return {
			organizationId,
			integrationName: mapped.integrationName,
			source: 'org-map',
			instanceHost: mapped.instanceHost ?? null,
		}
	}

	const live = await listOrgs()
	const match = live.orgs.find(
		(org) =>
			org.ok &&
			org.organizationId &&
			org.organizationId.slice(0, 15) === organizationId.slice(0, 15),
	)
	if (match?.organizationId) {
		return {
			organizationId: match.organizationId,
			integrationName: match.integrationName,
			source: 'live-discovery',
			instanceHost: match.instanceHost,
		}
	}

	const known = Object.keys(live.map)
	throw new Error(
		`No saved Salesforce connection maps to organization ${organizationId}. ` +
			(known.length > 0
				? `Known org ids: ${known.join(', ')}. Connect the org under a distinct integrationName (salesforce-<client>) and refresh the org-map.`
				: 'Connect the org under a distinct integrationName (salesforce-<client>), then call list-orgs and persist the map.'),
	)
}

export async function listSalesforceIntegrationNames(): Promise<Array<string>> {
	const listed = await kody.integrationList({})
	const rows = Array.isArray(listed)
		? listed
		: Array.isArray((listed as { integrations?: unknown }).integrations)
			? (listed as { integrations: Array<unknown> }).integrations
			: Array.isArray((listed as { items?: unknown }).items)
				? (listed as { items: Array<unknown> }).items
				: []

	const names = new Set<string>()
	for (const row of rows) {
		if (!row || typeof row !== 'object') continue
		const name = (row as { name?: unknown }).name
		if (typeof name === 'string' && isSalesforceIntegrationName(name)) names.add(name)
	}
	if (names.size === 0) names.add('salesforce')
	return [...names].sort()
}

function mappedHost(orgMap: OrgMap | undefined, organizationId: string | null): string | null {
	if (!organizationId) return null
	return lookupOrgMap(orgMap, organizationId)?.instanceHost ?? null
}

function optionalName(value: unknown): string | undefined {
	if (typeof value !== 'string') return undefined
	const trimmed = value.trim()
	return trimmed.length > 0 ? trimmed : undefined
}

function normalizeMapEntry(value: OrgMapEntry | string, organizationId: string): OrgMapEntry {
	if (typeof value === 'string') {
		return { integrationName: requireString(value, `orgMap[${organizationId}]`) }
	}
	return {
		integrationName: requireString(
			value.integrationName,
			`orgMap[${organizationId}].integrationName`,
		),
		label: typeof value.label === 'string' ? value.label : null,
		instanceHost: typeof value.instanceHost === 'string' ? value.instanceHost : null,
	}
}

function hostFromUserInfo(value: unknown): string | null {
	if (typeof value !== 'string' || value.length === 0) return null
	try {
		return new URL(value).host
	} catch {
		return null
	}
}

function findOrganizationId(value: unknown, depth: number, seen: Set<unknown>): string | null {
	if (value === undefined || value === null || depth > 6) return null
	if (typeof value === 'string') {
		const trimmed = value.trim()
		if (/^00D[a-zA-Z0-9]{12}([a-zA-Z0-9]{3})?$/.test(trimmed)) return trimmed
		const match = trimmed.match(ORG_ID_PATTERN)
		return match?.[0] ?? null
	}
	if (typeof value !== 'object') return null
	if (seen.has(value)) return null
	seen.add(value)
	if (Array.isArray(value)) {
		for (const item of value.slice(0, 20)) {
			const found = findOrganizationId(item, depth + 1, seen)
			if (found) return found
		}
		return null
	}

	const record = value as JsonRecord
	for (const key of ORG_ID_KEYS) {
		const field = record[key]
		if (typeof field === 'string' && field.trim()) {
			try {
				return normalizeOrganizationId(field)
			} catch {
				const nested = findOrganizationId(field, depth + 1, seen)
				if (nested) return nested
			}
		}
	}

	for (const nested of Object.values(record).slice(0, 30)) {
		const found = findOrganizationId(nested, depth + 1, seen)
		if (found) return found
	}
	return null
}

export function requireOrgResolveInput(params: unknown): ResolveOrgInput {
	const input = requireRecord(params, 'resolve-org')
	return {
		organizationId:
			typeof input.organizationId === 'string' ? input.organizationId : undefined,
		integrationName:
			typeof input.integrationName === 'string' ? input.integrationName : undefined,
		orgMap: isOrgMap(input.orgMap) ? input.orgMap : undefined,
		payload: input.payload,
		error: input.error,
		body: input.body,
	}
}

function isOrgMap(value: unknown): value is OrgMap {
	return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}