Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/alchemy/src/Cloudflare/Providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as Images from "./Images/index.ts";
import * as KV from "./KV/index.ts";
import * as Queue from "./Queue/index.ts";
import * as R2 from "./R2/index.ts";
import * as Ruleset from "./Ruleset/index.ts";
import * as SecretsStore from "./SecretsStore/index.ts";
import * as Tunnel from "./Tunnel/index.ts";
import * as VpcService from "./VpcService/index.ts";
Expand Down Expand Up @@ -73,6 +74,7 @@ export const providers = () =>
Queue.QueueEventSourcePolicy,
R2.R2Bucket,
R2.R2BucketBindingPolicy,
Ruleset.Ruleset,
SecretsStore.SecretBindingPolicy,
SecretsStore.SecretsStore,
SecretsStore.Secret,
Expand Down Expand Up @@ -112,6 +114,7 @@ export const providers = () =>
Queue.QueueConsumerProvider(),
R2.R2BucketBindingPolicyLive,
R2.R2BucketProvider(),
Ruleset.RulesetProvider(),
SecretsStore.SecretBindingPolicyLive,
SecretsStore.SecretsStoreProvider(),
SecretsStore.StoreSecretProvider(),
Expand Down
203 changes: 203 additions & 0 deletions packages/alchemy/src/Cloudflare/Ruleset/Ruleset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
import * as Effect from "effect/Effect";
import { deepEqual, isResolved } from "../../Diff.ts";
import { createPhysicalName } from "../../PhysicalName.ts";
import * as Provider from "../../Provider.ts";
import { Resource } from "../../Resource.ts";
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
import type { Providers } from "../Providers.ts";
import {
isZoneId,
resolveZoneId as resolveCloudflareZoneId,
type ZoneReference,
} from "../Zone.ts";
import { toRulesetAttributes } from "./attributes.ts";

export type RulesetPhase = rulesets.CreateRulesetForZoneRequest["phase"];
export type RulesetRule = NonNullable<
rulesets.PutPhasForZoneRequest["rules"]
>[number];
export type RulesetOutputRule = Omit<
NonNullable<rulesets.GetPhasResponse["rules"]>[number],
"lastUpdated" | "version"
>;

export type RulesetZone = ZoneReference;

export type RulesetProps<Phase extends RulesetPhase = RulesetPhase> = {
/**
* Zone to apply the ruleset to. Pass a zone ID string, a hostname in the
* zone, or any object with a `zoneId` attribute such as `Cloudflare.Zone`.
*/
zone: RulesetZone;
/**
* Ruleset phase entrypoint to own.
*/
phase: Phase;
/**
* Rules to apply to the phase entrypoint.
*/
rules: RulesetRule[];
/**
* Human-readable name for the ruleset.
* @default ${app}-${stage}-${id}
*/
name?: string;
/**
* Description for the ruleset.
*/
description?: string;
};

export type Ruleset<Phase extends RulesetPhase = RulesetPhase> = Resource<
"Cloudflare.Ruleset",
RulesetProps<Phase>,
{
rulesetId: string;
zoneId: string;
phase: Phase;
name: string;
description: string | undefined;
rules: RulesetOutputRule[];
lastUpdated: string;
version: string;
},
never,
Providers
>;

/**
* A Cloudflare Ruleset phase entrypoint for a zone.
*
* This resource owns the entire ruleset for a phase entrypoint. Rules managed
* elsewhere in the same phase can be overwritten on deploy.
*
* @section WAF Rules
* @example Block probes in the custom firewall phase
* ```typescript
* const waf = yield* Cloudflare.Ruleset("WafRules", {
* zone,
* phase: "http_request_firewall_custom",
* rules: [
* {
* description: "Block exploit probes",
* expression: `lower(http.request.uri.path) contains "/.env"`,
* action: "block",
* },
* ],
* });
* ```
*/
export const Ruleset = Resource<Ruleset>("Cloudflare.Ruleset")({});

const isNotFoundError = (error: unknown): boolean =>
typeof error === "object" &&
error !== null &&
(("status" in error && (error as { status: unknown }).status === 404) ||
("_tag" in error && (error as { _tag: unknown })._tag === "NotFound"));

const zoneRef = (zone: RulesetZone): string =>
typeof zone === "string" ? zone : zone.zoneId;

export const RulesetProvider = () =>
Provider.effect(
Ruleset,
Effect.gen(function* () {
const { accountId } = yield* CloudflareEnvironment;
const getPhas = yield* rulesets.getPhasForZone;
const putPhas = yield* rulesets.putPhasForZone;

const createRulesetName = (id: string, name: string | undefined) =>
Effect.gen(function* () {
return name ?? (yield* createPhysicalName({ id }));
});

const resolveZoneId = (zone: RulesetZone) =>
resolveCloudflareZoneId({
accountId,
zone,
hostname: typeof zone === "string" ? zone : (zone.name ?? ""),
});

return {
stables: ["zoneId", "phase"],
diff: Effect.fn(function* ({ id, olds, news, output }) {
if (!isResolved(news)) return undefined;
const desiredZone = zoneRef(news.zone);
const desiredZoneId =
typeof news.zone !== "string" || isZoneId(news.zone)
? desiredZone
: undefined;
const oldZone = olds.zone ? zoneRef(olds.zone) : undefined;
const oldZoneId =
olds.zone && (typeof olds.zone !== "string" || isZoneId(olds.zone))
? oldZone
: undefined;

if (
output?.zoneId &&
desiredZoneId &&
desiredZoneId !== output.zoneId
) {
return { action: "replace" } as const;
}
if (oldZoneId && desiredZoneId && oldZoneId !== desiredZoneId) {
return { action: "replace" } as const;
}
if (
olds.zone &&
typeof olds.zone === "string" &&
typeof news.zone === "string" &&
oldZone !== desiredZone
) {
return { action: "replace" } as const;
}
if (olds.phase !== news.phase) {
return { action: "replace" } as const;
}

const oldName =
output?.name ?? (yield* createRulesetName(id, olds.name));
const name = yield* createRulesetName(id, news.name);
if (
oldName !== name ||
olds.description !== news.description ||
!deepEqual(olds.rules, news.rules)
) {
return { action: "update" } as const;
}
}),
reconcile: Effect.fn(function* ({ id, news, output }) {
const zoneId = output?.zoneId ?? (yield* resolveZoneId(news.zone));
const name = yield* createRulesetName(id, news.name ?? output?.name);
const ruleset = yield* putPhas({
zoneId,
rulesetPhase: news.phase,
name,
description: news.description,
rules: news.rules,
});
return toRulesetAttributes<typeof news.phase>(zoneId, ruleset);
}),
delete: Effect.fn(function* ({ olds, output }) {
yield* putPhas({
zoneId: output.zoneId,
rulesetPhase: olds.phase,
name: output.name,
description: output.description,
rules: [],
}).pipe(Effect.catchIf(isNotFoundError, () => Effect.void));
}),
read: Effect.fn(function* ({ olds, output }) {
const zoneId = output?.zoneId ?? (yield* resolveZoneId(olds.zone));
return yield* getPhas({
zoneId,
rulesetPhase: output?.phase ?? olds.phase,
}).pipe(
Effect.map((ruleset) => toRulesetAttributes(zoneId, ruleset)),
Effect.catchIf(isNotFoundError, () => Effect.succeed(undefined)),
);
}),
};
}),
);
18 changes: 18 additions & 0 deletions packages/alchemy/src/Cloudflare/Ruleset/attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type * as rulesets from "@distilled.cloud/cloudflare/rulesets";
import type { Ruleset, RulesetPhase } from "./Ruleset.ts";

type RulesetResponse = rulesets.GetPhasResponse | rulesets.PutPhasResponse;

export const toRulesetAttributes = <Phase extends RulesetPhase>(
zoneId: string,
ruleset: RulesetResponse,
): Ruleset<Phase>["Attributes"] => ({
rulesetId: ruleset.id,
zoneId,
phase: ruleset.phase as Phase,
name: ruleset.name,
description: ruleset.description ?? undefined,
rules: (ruleset.rules ?? []).map(({ lastUpdated, version, ...rule }) => rule),
lastUpdated: ruleset.lastUpdated,
version: ruleset.version,
});
1 change: 1 addition & 0 deletions packages/alchemy/src/Cloudflare/Ruleset/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Ruleset.ts";
1 change: 1 addition & 0 deletions packages/alchemy/src/Cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from "./KV/index.ts";
export * from "./Providers.ts";
export * from "./Queue/index.ts";
export * from "./R2/index.ts";
export * from "./Ruleset/index.ts";
export * from "./SecretsStore/index.ts";
export * from "./StateStore/index.ts";
export * from "./Tunnel/index.ts";
Expand Down
95 changes: 95 additions & 0 deletions packages/alchemy/test/Cloudflare/Ruleset/Ruleset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as Cloudflare from "@/Cloudflare";
import * as Test from "@/Test/Vitest";
import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
import { expect } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { MinimumLogLevel } from "effect/References";

const { test } = Test.make({ providers: Cloudflare.providers() });

const logLevel = Effect.provideService(
MinimumLogLevel,
process.env.DEBUG ? "Debug" : "Info",
);

const zoneName =
process.env.CLOUDFLARE_TEST_RULESET_ZONE_NAME ?? "alchemy-test-2.us";
const phase = "http_request_firewall_custom";
type TestRulesetPhase = typeof phase;

test.provider(
"creates, updates, and deletes a zone phase entrypoint ruleset",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();

const initial = yield* stack.deploy(
Effect.gen(function* () {
return yield* Cloudflare.Ruleset("TestRuleset", {
zone: zoneName,
phase,
rules: [
{
description: "Alchemy test rule",
expression:
'http.request.uri.path eq "/__alchemy_ruleset_test__"',
action: "block",
},
],
});
}),
);

expect(initial.phase).toEqual(phase);
expect(initial.rules).toHaveLength(1);

const updated = yield* stack.deploy(
Effect.gen(function* () {
return yield* Cloudflare.Ruleset("TestRuleset", {
zone: zoneName,
phase,
rules: [
{
description: "Updated Alchemy test rule",
expression:
'http.request.uri.path eq "/__alchemy_ruleset_test__"',
action: "managed_challenge",
},
],
});
}),
);

expect(updated.zoneId).toEqual(initial.zoneId);
expect(updated.rules[0]?.description).toEqual(
"Updated Alchemy test rule",
);
expect(updated.rules[0]?.action).toEqual("managed_challenge");

yield* stack.destroy();

const actualRules = yield* getPhaseRules(initial.zoneId, phase);
expect(actualRules).toEqual([]);
}).pipe(logLevel),
);

const getPhaseRules = Effect.fn(function* (
zoneId: string,
phase: TestRulesetPhase,
) {
return yield* rulesets
.getPhasForZone({
zoneId,
rulesetPhase: phase,
})
.pipe(
Effect.map((ruleset) => ruleset.rules ?? []),
Effect.catchIf(isNotFoundError, () => Effect.succeed([])),
);
});

const isNotFoundError = (error: unknown): boolean =>
typeof error === "object" &&
error !== null &&
(("status" in error && (error as { status: unknown }).status === 404) ||
("_tag" in error && (error as { _tag: unknown })._tag === "NotFound"));
44 changes: 44 additions & 0 deletions packages/alchemy/test/Cloudflare/Ruleset/RulesetAttributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { toRulesetAttributes } from "@/Cloudflare/Ruleset/attributes";
import type * as rulesets from "@distilled.cloud/cloudflare/rulesets";
import { expect, it } from "@effect/vitest";

const emptyPhaseEntrypoint = {
id: "ruleset-123",
kind: "zone",
lastUpdated: "2026-05-12T09:04:24.408357Z",
name: "alchemy-test-ruleset",
phase: "http_request_firewall_custom",
version: "18",
description: "",
} as rulesets.GetPhasResponse;

it("normalizes Cloudflare phase entrypoint responses that omit rules", () => {
expect(toRulesetAttributes("zone-123", emptyPhaseEntrypoint).rules).toEqual(
[],
);
});

it("strips Cloudflare rule metadata from managed ruleset output", () => {
const withRule = {
...emptyPhaseEntrypoint,
rules: [
{
id: "rule-123",
lastUpdated: "2026-05-12T09:04:24.408357Z",
version: "1",
description: "Alchemy test rule",
expression: 'http.request.uri.path eq "/__alchemy_ruleset_test__"',
action: "block",
},
],
} as rulesets.GetPhasResponse;

expect(toRulesetAttributes("zone-123", withRule).rules).toEqual([
{
id: "rule-123",
description: "Alchemy test rule",
expression: 'http.request.uri.path eq "/__alchemy_ruleset_test__"',
action: "block",
},
]);
});
Loading