Skip to content

src/auth.ts

252 lines · 8.6 KB · TypeScript
Raw
export type CloudflareAccount = 'default' | 'kody' | 'pages'

/**
 * Known Cloudflare account ids this package can default for an alias.
 * `null` means the alias does not pin an account — callers pass `accountId`
 * (or discover it via GET /accounts with a token that can list them).
 */
export type CloudflareAccountInfo = {
	account: CloudflareAccount
	label: string
	default: boolean
	secretName: string
	/**
	 * Cloudflare account id when the alias maps to one known account.
	 * - `kody` → Kody production account
	 * - `pages` → Kent's personal "KCD" account (Pages token is scoped there)
	 * - `default` → `null` (token covers multiple accounts; pass `accountId`)
	 */
	accountId: string | null
	/** Friendly Cloudflare account name when known. */
	accountName: string | null
	useWhen: string
	avoidWhen: string
	/** Capabilities this alias's token typically has. */
	can: string[]
	/** Things this alias's token typically cannot do. */
	cannot: string[]
}

export type CloudflareAuthOptions = {
	/**
	 * Credential alias mapped to a known Kody secret. Defaults to `default`
	 * (`cloudflareApiToken`).
	 */
	account?: CloudflareAccount
	/**
	 * Explicit Kody secret name when no alias covers the token. Never pass a raw
	 * token value — only the secret's name. Mutually exclusive with `account`.
	 */
	apiTokenSecret?: string
}

export const DEFAULT_CLOUDFLARE_ACCOUNT: CloudflareAccount = 'default'

/** Kody production Cloudflare account id (`account: 'kody'`). */
export const KODY_CLOUDFLARE_ACCOUNT_ID = 'a99ee2e72728dd52902ef288b7b1447d'

/**
 * Kent's personal "KCD" Cloudflare account id. The `pages` alias token is
 * scoped to this account (GET /accounts returns empty for that token, so the
 * id is baked into the alias metadata).
 */
export const KCD_CLOUDFLARE_ACCOUNT_ID = 'a41d50ecaf0ae0f86dd1824ef6729cb2'

const cloudflareAccounts = [
	{
		account: 'default',
		label: 'Default Cloudflare API token',
		default: true,
		secretName: 'cloudflareApiToken',
		accountId: null,
		accountName: null,
		useWhen:
			'Routine Cloudflare API work against accounts and zones covered by the primary (user-scoped) token. Covers multiple accounts — pass accountId for account-scoped resources.',
		avoidWhen:
			'The primary token is not authorized for the target account (for example the Kody Cloudflare account), or you need the dedicated Pages token.',
		can: [
			'List accounts the token can see (GET /accounts)',
			'Zones, Workers, D1, R2, KV, Queues, analytics, Log Explorer, WAF rulesets on covered accounts',
			'Pages projects on accounts the token can access (often including KCD)',
		],
		cannot: [
			'Guess a single default accountId — this token spans multiple accounts',
			'Kody-account resources the kody alias owns exclusively',
		],
	},
	{
		account: 'kody',
		label: 'Kody Cloudflare account token',
		default: false,
		secretName: 'cloudflareApiTokenKodyAccount',
		accountId: KODY_CLOUDFLARE_ACCOUNT_ID,
		accountName: 'Kody',
		useWhen:
			'Calls against the Kody Cloudflare account (Workers, D1, R2, Log Explorer on that account). Typically an account-scoped token — verify with kody:@kentcdodds/cloudflare/verify ({ account: "kody" }), not /user/tokens/verify alone.',
		avoidWhen: 'Zones and resources that the default token already covers, or Pages on the KCD account.',
		can: [
			'Workers, D1, R2, KV, Queues, Log Explorer on the Kody account',
			'GET /accounts for the Kody account only',
			'Account-scoped token verify at /accounts/{id}/tokens/verify',
		],
		cannot: [
			'KCD (personal) account Pages / zones',
			'User-scoped /user/tokens/verify (expect 401 — use ./verify instead)',
		],
	},
	{
		account: 'pages',
		label: 'Cloudflare Pages API token',
		default: false,
		secretName: 'cloudflarePagesApiToken',
		accountId: KCD_CLOUDFLARE_ACCOUNT_ID,
		accountName: 'KCD',
		useWhen:
			'Cloudflare Pages Direct Upload and Pages project management on the KCD (personal) account. Prefer publishStatic over hand-rolling the upload JWT + multipart flow.',
		avoidWhen:
			'General Workers/D1/R2/analytics/WAF work (use default or kody). DNS edits for custom domains (Pages tokens usually lack Zone DNS edit).',
		can: [
			'List / create / deploy Pages projects on the KCD account',
			'Pages Direct Upload (upload JWT + multipart deployment) — use ./publish-static',
		],
		cannot: [
			'List accounts via GET /accounts (returns empty even when valid — use the baked-in accountId)',
			'R2 / most non-Pages resources',
			'Edit zone DNS for custom Pages domains (document the hostname; configure DNS separately)',
			'Kody-account Pages (there usually are none; use the KCD accountId)',
		],
	},
] as const satisfies readonly CloudflareAccountInfo[]

const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/

/**
 * Return the supported Cloudflare account aliases and selection guidance.
 */
export function accounts(): readonly CloudflareAccountInfo[] {
	return cloudflareAccounts
}

/**
 * Return documentation for one Cloudflare account alias.
 */
export function getAccountInfo(
	account: CloudflareAccount = DEFAULT_CLOUDFLARE_ACCOUNT,
): CloudflareAccountInfo {
	const info = cloudflareAccounts.find((candidate) => candidate.account === account)
	if (!info) {
		throw new Error('Unsupported Cloudflare account: ' + String(account))
	}
	return info
}

/**
 * Resolve a Cloudflare account id from an explicit value or a known alias.
 * Returns `null` when neither is available (caller must pass `accountId`).
 */
export function resolveKnownAccountId(options: {
	account?: CloudflareAccount
	accountId?: string
}): string | null {
	if (typeof options.accountId === 'string' && options.accountId.trim()) {
		return options.accountId.trim()
	}
	const account = options.account ?? DEFAULT_CLOUDFLARE_ACCOUNT
	return getAccountInfo(account).accountId
}

const KNOWN_API_TOKEN_SECRETS = [
	'cloudflareApiToken',
	'cloudflareApiTokenKodyAccount',
	'cloudflarePagesApiToken',
] as const

type KnownApiTokenSecret = (typeof KNOWN_API_TOKEN_SECRETS)[number]

/**
 * Resolve the Kody secret name for an optional account alias or explicit secret.
 * Only the three declared Cloudflare token slots are stampable.
 */
export function resolveApiTokenSecretName(options: CloudflareAuthOptions = {}): KnownApiTokenSecret {
	const hasExplicit =
		options.apiTokenSecret !== undefined &&
		options.apiTokenSecret !== null &&
		String(options.apiTokenSecret).trim() !== ''
	if (hasExplicit && options.account !== undefined) {
		throw new Error('Pass either account or apiTokenSecret, not both.')
	}
	if (hasExplicit) {
		const name = String(options.apiTokenSecret).trim()
		if (!SECRET_NAME.test(name)) {
			throw new Error(
				'apiTokenSecret must be a simple Kody secret name (letters, numbers, underscore).',
			)
		}
		switch (name) {
			case 'cloudflareApiToken':
			case 'cloudflareApiTokenKodyAccount':
			case 'cloudflarePagesApiToken':
				return name
			default:
				throw new Error(
					'apiTokenSecret must be one of: cloudflareApiToken, cloudflareApiTokenKodyAccount, cloudflarePagesApiToken.',
				)
		}
	}
	const account = options.account ?? DEFAULT_CLOUDFLARE_ACCOUNT
	switch (account) {
		case 'default':
			return 'cloudflareApiToken'
		case 'kody':
			return 'cloudflareApiTokenKodyAccount'
		case 'pages':
			return 'cloudflarePagesApiToken'
		default: {
			const exhaustive: never = account
			throw new Error('Unsupported Cloudflare account: ' + String(exhaustive))
		}
	}
}

/**
 * Build the Authorization header value using a `{{secret:name}}` placeholder.
 * Never accepts or returns a raw token value.
 */
export function getAuthorizationHeader(options: CloudflareAuthOptions = {}): string {
	const secretName = resolveApiTokenSecretName(options)
	switch (secretName) {
		case 'cloudflareApiToken':
			return 'Bearer {{secret:cloudflareApiToken}}'
		case 'cloudflareApiTokenKodyAccount':
			return 'Bearer {{secret:cloudflareApiTokenKodyAccount}}'
		case 'cloudflarePagesApiToken':
			return 'Bearer {{secret:cloudflarePagesApiToken}}'
		default: {
			const exhaustive: never = secretName
			throw new Error('Unsupported Cloudflare API token secret: ' + String(exhaustive))
		}
	}
}

/**
 * Common Cloudflare API headers including the selected token placeholder.
 */
export function cloudflareAuthHeaders(
	options: CloudflareAuthOptions = {},
): Record<string, string> {
	return {
		Accept: 'application/json',
		Authorization: getAuthorizationHeader(options),
		'User-Agent': 'kody-cloudflare/1.0',
	}
}

/**
 * Pick auth-related fields from a wider params object.
 */
export function pickAuthOptions(params: CloudflareAuthOptions = {}): CloudflareAuthOptions {
	const out: CloudflareAuthOptions = {}
	if (params.account !== undefined) out.account = params.account
	if (params.apiTokenSecret !== undefined) out.apiTokenSecret = params.apiTokenSecret
	return out
}