Skip to content

src/accounts-resolve.ts

67 lines · 2.6 KB · TypeScript
Raw
import cloudflareApiV4List from './api-v4-list.ts'
import {
	DEFAULT_CLOUDFLARE_ACCOUNT,
	pickAuthOptions,
	resolveApiTokenSecretName,
	resolveKnownAccountId,
	type CloudflareAuthOptions,
} from './auth.ts'

export type AccessibleAccount = { id: string; name: string }

/** List accounts the token can see via GET /accounts (empty for Pages-only tokens). */
export async function listAccessibleAccounts(
	auth: CloudflareAuthOptions,
): Promise<{ ok: boolean; accounts: AccessibleAccount[]; httpStatus: number | null }> {
	const res = await cloudflareApiV4List<{ id?: string; name?: string }>({
		...pickAuthOptions(auth),
		path: '/client/v4/accounts',
		maxPages: 10,
	})
	return {
		ok: res.ok,
		httpStatus: res.httpStatus,
		accounts: res.items
			.filter((account) => typeof account.id === 'string')
			.map((account) => ({ id: account.id as string, name: String(account.name ?? '') })),
	}
}

/**
 * Resolve the Cloudflare account id for an account-scoped call: explicit
 * `accountId`, then the alias's baked-in id, then GET /accounts when the token
 * sees exactly one account. Throws with the options when ambiguous.
 */
export async function resolveAccountId(
	params: CloudflareAuthOptions & { accountId?: string },
): Promise<{ accountId: string; source: 'param' | 'alias' | 'discovered' }> {
	if (typeof params.accountId === 'string' && params.accountId.trim()) {
		const accountId = params.accountId.trim()
		if (!/^[a-f0-9]{32}$/.test(accountId)) {
			throw new Error('accountId must be a 32-character hex Cloudflare account id.')
		}
		return { accountId, source: 'param' }
	}
	if (!params.apiTokenSecret) {
		const known = resolveKnownAccountId({ account: params.account })
		if (known) return { accountId: known, source: 'alias' }
	}
	const listed = await listAccessibleAccounts(params)
	if (listed.accounts.length === 1) {
		return { accountId: listed.accounts[0].id, source: 'discovered' }
	}
	const secret = resolveApiTokenSecretName(pickAuthOptions(params))
	const alias = params.apiTokenSecret ? `secret ${secret}` : `account "${params.account ?? DEFAULT_CLOUDFLARE_ACCOUNT}"`
	if (listed.accounts.length > 1) {
		throw new Error(
			`accountId is required: ${alias} can access ${listed.accounts.length} accounts — ` +
				listed.accounts.map((account) => `${account.name} (${account.id})`).join(', ') +
				'. Pass accountId.',
		)
	}
	throw new Error(
		`accountId is required: ${alias} cannot list accounts (GET /accounts returned none). ` +
			'Copy the account id from the Cloudflare dashboard URL (dash.cloudflare.com/<accountId>/…) or ' +
			'Workers & Pages → Account details, and pass accountId.',
	)
}