Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/cloudflare

src/auth.ts

144 lines · 4.6 KB · TypeScript
export type CloudflareAccount = 'default' | 'kody' | 'pages'

export type CloudflareAccountInfo = {
  account: CloudflareAccount
  label: string
  default: boolean
  secretName: string
  useWhen: string
  avoidWhen: 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'

const cloudflareAccounts = [
  {
    account: 'default',
    label: 'Default Cloudflare API token',
    default: true,
    secretName: 'cloudflareApiToken',
    useWhen:
      'Routine Cloudflare API work against accounts and zones covered by the primary token.',
    avoidWhen:
      'The primary token is not authorized for the target account (for example the Kody Cloudflare account).',
  },
  {
    account: 'kody',
    label: 'Kody Cloudflare account token',
    default: false,
    secretName: 'cloudflareApiTokenKodyAccount',
    useWhen:
      'Calls against the Kody Cloudflare account (for example Kody production D1, Workers, or 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.',
  },
  {
    account: 'pages',
    label: 'Cloudflare Pages API token',
    default: false,
    secretName: 'cloudflarePagesApiToken',
    useWhen: 'Cloudflare Pages operations that require the dedicated Pages token.',
    avoidWhen: 'General API, analytics, logs, or WAF work better served by default or kody.',
  },
] 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 the Kody secret name for an optional account alias or explicit secret.
 */
export function resolveApiTokenSecretName(options: CloudflareAuthOptions = {}): string {
  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).',
      )
    }
    return name
  }
  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)
  return `Bearer {{secret:${secretName}}}`
}

/**
 * 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
}