import { findZone } from './analytics.ts'
import {
cloudflareAuthHeaders,
pickAuthOptions,
type CloudflareAuthOptions,
} from './auth.ts'
const BASE_URL = 'https://api.cloudflare.com/client/v4'
const DEFAULT_PHASE = 'http_request_firewall_custom'
const IDENTIFIER = /^[A-Za-z0-9_-]+$/
const ACTIONS = new Set(['block', 'challenge', 'js_challenge', 'managed_challenge', 'log'])
function normalizeArray(value, fallback = []) {
if (value === undefined || value === null) return fallback
return Array.isArray(value) ? value : [value]
}
function quoteExpressionString(value) {
return '"' + String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
}
function assertIdentifier(value, label) {
const identifier = String(value || '').trim()
if (!IDENTIFIER.test(identifier)) {
throw new Error(label + ' must contain only letters, numbers, underscores, or hyphens.')
}
return identifier
}
function normalizeAction(action = 'block') {
const value = String(action || '').trim()
if (!ACTIONS.has(value)) {
throw new Error('action must be one of ' + Array.from(ACTIONS).join(', ') + '.')
}
return value
}
function normalizeBlockResponse(response = {}) {
const statusCode = Number(response.status_code ?? response.statusCode ?? 404)
if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) {
throw new Error('response status code must be an HTTP status code.')
}
return {
status_code: statusCode,
content: String(response.content ?? 'Not found'),
content_type: String(response.content_type ?? response.contentType ?? 'text/plain'),
}
}
function buildBlockActionParameters(params) {
if (params.response === false) return undefined
const response = params.response && typeof params.response === 'object' ? params.response : {}
return {
response: normalizeBlockResponse({
...response,
status_code: params.responseStatusCode ?? params.statusCode ?? response.status_code ?? response.statusCode,
content: params.responseContent ?? response.content,
content_type: params.responseContentType ?? params.contentType ?? response.content_type ?? response.contentType,
}),
}
}
function hasBlockResponseParams(params) {
return Boolean(
params.response ||
params.responseStatusCode ||
params.statusCode ||
params.responseContent ||
params.responseContentType ||
params.contentType,
)
}
function summarizeErrors(errors) {
return normalizeArray(errors).map((error) => ({
code: error?.code,
message: error?.message || String(error),
}))
}
async function cloudflareRequest(path, init = {}, auth = {}) {
const response = await fetch(BASE_URL + path, {
...init,
headers: {
...cloudflareAuthHeaders(pickAuthOptions(auth)),
...(init.headers || {}),
},
})
const body = await response.json().catch(() => null)
if (!body) {
throw new Error('Cloudflare API returned non-JSON (' + response.status + ').')
}
return {
status: response.status,
success: body.success === true,
result: body.result,
errors: body.errors || [],
messages: body.messages || [],
}
}
async function resolveZoneId(params) {
if (params.zoneId) return String(params.zoneId)
const zone = await findZone(params)
if (!zone.zone?.id) {
throw new Error('No Cloudflare zone found for ' + (params.name || params.zoneName || 'input') + '.')
}
return zone.zone.id
}
function normalizeRule(rule) {
if (!rule || typeof rule !== 'object') throw new Error('rule is required.')
const expression = String(rule.expression || '').trim()
if (!expression) throw new Error('rule.expression is required.')
const normalized = {
action: normalizeAction(rule.action),
expression,
description: String(rule.description || 'Kody-managed Cloudflare rule'),
enabled: rule.enabled !== false,
}
if (rule.ref) normalized.ref = assertIdentifier(rule.ref, 'rule.ref')
const actionParameters = rule.action_parameters || rule.actionParameters
if (actionParameters && typeof actionParameters === 'object') {
normalized.action_parameters = actionParameters
}
return normalized
}
/**
* Get the zone phase entry point ruleset.
*/
export async function getZoneEntrypointRuleset(params = {}) {
const zoneId = await resolveZoneId(params)
const phase = String(params.phase || DEFAULT_PHASE)
const result = await cloudflareRequest(
'/zones/' + encodeURIComponent(zoneId) + '/rulesets/phases/' + encodeURIComponent(phase) + '/entrypoint',
{},
params,
)
return { ...result, zoneId, phase }
}
/**
* Build a Cloudflare Rules language expression for bogus crawler paths.
*/
export function buildBogusRouteExpression(params = {}) {
const contains = normalizeArray(params.contains, ['/node_modules/'])
const prefixes = normalizeArray(params.prefixes, ['/calls/'])
const suffixes = normalizeArray(params.suffixes, [
'/Express.js',
'/Next.js',
'/React.js',
'/index.js',
'/meta.json',
'/u003e',
])
const exactPaths = normalizeArray(params.exactPaths, [])
const clauses = [
...contains.map((value) => 'http.request.uri.path contains ' + quoteExpressionString(value)),
...exactPaths.map((value) => 'http.request.uri.path eq ' + quoteExpressionString(value)),
]
if (prefixes.length && suffixes.length) {
const prefixClause = prefixes
.map((value) => 'starts_with(http.request.uri.path, ' + quoteExpressionString(value) + ')')
.join(' or ')
const suffixClause = suffixes
.map((value) => 'ends_with(http.request.uri.path, ' + quoteExpressionString(value) + ')')
.join(' or ')
clauses.push('((' + prefixClause + ') and (' + suffixClause + '))')
}
if (!clauses.length) throw new Error('At least one route matcher is required.')
return clauses.length === 1 ? clauses[0] : '(' + clauses.join(' or ') + ')'
}
/**
* Build a reusable custom ruleset rule for bogus crawler paths.
*/
export function buildBogusRouteRule(params = {}) {
const action = params.action || 'block'
const actionParameters = params.action_parameters || params.actionParameters
const rule = {
ref: params.ref || 'kody_bogus_crawler_routes',
description: params.description || 'Block bogus crawler routes before they reach origin',
action,
enabled: params.enabled,
expression: params.expression || buildBogusRouteExpression(params),
}
if (actionParameters) {
rule.action_parameters = actionParameters
} else if (action === 'block' && hasBlockResponseParams(params)) {
rule.action_parameters = buildBlockActionParameters(params)
}
return normalizeRule({
...rule,
})
}
function upsertRule(rules, rule) {
const ref = rule.ref
if (!ref) return [...rules, rule]
const index = rules.findIndex((existing) => existing.ref === ref)
if (index === -1) return [...rules, rule]
return rules.map((existing, itemIndex) => (itemIndex === index ? { ...existing, ...rule } : existing))
}
/**
* Preview or update a zone custom firewall entry point ruleset.
*
* Defaults to dry-run. Pass `apply: true` or `dryRun: false` to write the rule.
* Requires a Cloudflare token with zone Rulesets/WAF write permission when
* applying.
*/
export async function upsertZoneCustomFirewallRule(params = {}) {
const zoneId = await resolveZoneId(params)
const phase = String(params.phase || DEFAULT_PHASE)
const rule = normalizeRule(params.rule || buildBogusRouteRule(params))
const dryRun = params.apply === true ? false : params.dryRun !== false
const current = await getZoneEntrypointRuleset({ ...pickAuthOptions(params), zoneId, phase }).catch((error) => ({
status: 0,
success: false,
result: null,
errors: [{ message: error instanceof Error ? error.message : String(error) }],
messages: [],
}))
const canCreateEntrypoint = current.status === 404
if (!dryRun && !current.success && !canCreateEntrypoint) {
return {
status: 'failed',
reason: 'could_not_read_entrypoint',
zoneId,
phase,
rule,
currentStatus: current.status,
currentErrors: summarizeErrors(current.errors),
notes: [
'No Cloudflare rule was changed.',
'Applying a phase entry point ruleset replaces the complete rules list.',
'Grant the token Zone WAF read/write permission, then retry apply: true.',
],
}
}
const existingRules = normalizeArray(current.result?.rules)
const nextRules = upsertRule(existingRules, rule)
const body = {
rules: nextRules,
}
const endpoint =
'/zones/' + encodeURIComponent(zoneId) + '/rulesets/phases/' + encodeURIComponent(phase) + '/entrypoint'
if (dryRun) {
return {
status: 'dry_run',
zoneId,
phase,
endpoint,
method: 'PUT',
rule,
currentStatus: current.status,
currentSuccess: current.success,
currentRules: existingRules.length,
nextRules: nextRules.length,
body,
currentErrors: summarizeErrors(current.errors),
notes: [
'No Cloudflare rule was changed.',
'Pass apply: true to write this entry point ruleset.',
'Applying replaces the complete phase entry point ruleset with the returned body.',
],
}
}
const result = await cloudflareRequest(
endpoint,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
params,
)
return {
status: result.success ? 'applied' : 'failed',
zoneId,
phase,
endpoint,
rule,
result: result.result,
errors: result.errors,
messages: result.messages,
}
}
export type CloudflareRulesetsInput = CloudflareAuthOptions & {
mode?: 'get-entrypoint' | 'expression' | 'rule'
zoneId?: string
zoneName?: string
name?: string
phase?: string
apply?: boolean
dryRun?: boolean
rule?: Record<string, unknown>
ref?: string
action?: string
expression?: string
[key: string]: unknown
}
/**
* Preview or apply a zone WAF custom firewall rule (defaults to dry-run).
* @param params.zoneName - Zone hostname to target.
* @param params.account - Token alias (`default`, `kody`, `pages`); defaults to `default`.
* @param params.apiTokenSecret - Explicit Kody secret name when no alias fits (not a raw token).
* @returns Dry-run preview with the full entrypoint body, or apply result when `apply: true`.
* @example
* import rulesets from 'kody:@kentcdodds/cloudflare/rulesets'
* const result = await rulesets({ zoneName: 'example.com' })
* // => { status: 'dry_run', rule: { ... }, body: { rules: [...] }, ... }
*/
export default async function cloudflareRulesets(params: CloudflareRulesetsInput = {}) {
if (params.mode === 'get-entrypoint') return await getZoneEntrypointRuleset(params)
if (params.mode === 'expression') return { expression: buildBogusRouteExpression(params) }
if (params.mode === 'rule') return { rule: buildBogusRouteRule(params) }
return await upsertZoneCustomFirewallRule(params)
}