1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Dlp.Entry" as const;
14type TypeId = typeof TypeId;
15
16export interface EntryProps {
17 /**
18 * Name of the entry. If omitted, a unique name is generated from the
19 * app, stage, and logical ID.
20 * @default ${app}-${stage}-${id}
21 */
22 name?: string;
23 /**
24 * Whether the entry participates in scans.
25 * @default true
26 */
27 enabled?: boolean;
28 /**
29 * The detection pattern.
30 */
31 pattern: {
32 /** The regular expression to match. */
33 regex: string;
34 /** Optional checksum validation applied to matches. */
35 validation?: "luhn";
36 };
37 /**
38 * Optional description of the entry.
39 */
40 description?: string;
41 /**
42 * The custom DLP profile to attach the entry to at create time.
43 * Immutable — changing it triggers a replacement.
44 */
45 profileId?: string;
46}
47
48export type EntryAttributes = {
49 /** API UUID of the entry. */
50 entryId: string;
51 /** Account that owns the entry. */
52 accountId: string;
53 /** Observed entry name. */
54 name: string;
55 /** Whether the entry participates in scans. */
56 enabled: boolean;
57 /** Observed detection pattern. */
58 pattern: { regex: string; validation: "luhn" | (string & {}) | undefined };
59 /** The profile the entry is attached to, if any. */
60 profileId: string | undefined;
61};
62
63export type Entry = Resource<
64 TypeId,
65 EntryProps,
66 EntryAttributes,
67 never,
68 Providers
69>;
70
71/**
72 * A Cloudflare Zero Trust **DLP custom entry** — a standalone regular-
73 * expression detection that can be attached to a custom DLP profile.
74 * Use it when entries are managed independently of the
75 * {@link Profile} that groups them.
76 *
77 * Requires the Cloudflare DLP entitlement (a paid Zero Trust add-on);
78 * accounts without it receive the typed `Forbidden` error on all writes.
79 * ### Creating a DLP entry
80 * **Example:** Attach a regex entry to a profile
81 * ```typescript
82 * const entry = yield* Cloudflare.Dlp.Entry("EmployeeId", {
83 * pattern: { regex: "EMP-[0-9]{6}" },
84 * profileId: profile.profileId,
85 * });
86 * ```
87 *
88 * **Example:** Luhn-validated card entry
89 * ```typescript
90 * const card = yield* Cloudflare.Dlp.Entry("CardNumber", {
91 * pattern: { regex: "[0-9]{13,16}", validation: "luhn" },
92 * profileId: profile.profileId,
93 * });
94 * ```
95 *
96 * @see https://developers.cloudflare.com/cloudflare-one/policies/data-loss-prevention/dlp-profiles/
97 *
98 * @resource
99 * @product DLP
100 * @category Cloudflare One (Zero Trust)
101 */
102export const Entry = Resource<Entry>(TypeId);
103
104/**
105 * Returns true if the given value is a Entry resource.
106 */
107export const isEntry = (value: unknown): value is Entry =>
108 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
109
110export const EntryProvider = () =>
111 Provider.succeed(Entry, {
112 stables: ["entryId", "accountId", "profileId"],
113
114 diff: Effect.fn(function* ({ olds, news, output }) {
115 if (!isResolved(news)) return undefined;
116 // The profile association is set at create time only — replace
117 // when it changes.
118 const oldProfileId = output?.profileId ?? olds?.profileId;
119 if (oldProfileId !== undefined && oldProfileId !== news.profileId) {
120 return { action: "replace" } as const;
121 }
122 return undefined;
123 }),
124
125 read: Effect.fn(function* ({ output }) {
126 const { accountId } = yield* yield* CloudflareEnvironment;
127 const acct = output?.accountId ?? accountId;
128
129 // Entries are only refreshed by id; without a persisted id there is
130 // nothing safe to adopt (the entitlement is account-gated).
131 if (!output?.entryId) return undefined;
132 const observed = yield* observeEntry(acct, output.entryId);
133 return observed ? toAttributes(observed, acct) : undefined;
134 }),
135
136 // Account collection: enumerate every custom DLP entry in the account
137 // (GET /accounts/{id}/dlp/entries), exhaustively paginated. The list
138 // response is a union of entry variants; only the `custom` variant is
139 // an alchemy-managed Entry, so we narrow to it and hydrate into the
140 // exact `read` Attributes shape.
141 list: Effect.fn(function* () {
142 const { accountId } = yield* yield* CloudflareEnvironment;
143 return yield* zeroTrust.listDlpEntryCustoms.pages({ accountId }).pipe(
144 Stream.runCollect,
145 Effect.map((chunk) =>
146 Array.from(chunk).flatMap((page) =>
147 (page.result ?? [])
148 .filter(
149 (entry): entry is zeroTrust.DlpEntriesListResultItemCase0 =>
150 "type" in entry && entry.type === "custom",
151 )
152 .map((entry) => toAttributes(entry, accountId)),
153 ),
154 ),
155 );
156 }),
157
158 reconcile: Effect.fn(function* ({ id, news, output }) {
159 const { accountId } = yield* yield* CloudflareEnvironment;
160 const name = yield* createEntryName(id, news.name);
161
162 // 1. Observe.
163 const observed = output?.entryId
164 ? yield* observeEntry(accountId, output.entryId)
165 : undefined;
166
167 // 2. Ensure — create when missing.
168 if (!observed) {
169 const created = yield* zeroTrust.createDlpEntryCustom({
170 accountId,
171 name,
172 enabled: news.enabled ?? true,
173 pattern: encodePattern(news.pattern),
174 ...(news.description !== undefined
175 ? { description: news.description }
176 : {}),
177 ...(news.profileId !== undefined
178 ? { profileId: news.profileId }
179 : {}),
180 });
181 return toAttributes(created, accountId);
182 }
183
184 // 3. Sync — PUT the full desired state when the observed state
185 // differs; skip the call on a no-op.
186 const dirty =
187 observed.name !== name ||
188 observed.enabled !== (news.enabled ?? true) ||
189 observed.pattern.regex !== news.pattern.regex ||
190 (observed.pattern.validation ?? undefined) !== news.pattern.validation;
191 if (!dirty) {
192 return toAttributes(observed, accountId);
193 }
194 const updated = yield* zeroTrust.updateDlpEntryCustom({
195 accountId,
196 entryId: observed.id,
197 name,
198 enabled: news.enabled ?? true,
199 pattern: encodePattern(news.pattern),
200 ...(news.description !== undefined
201 ? { description: news.description }
202 : {}),
203 });
204 return toAttributes(updated, accountId);
205 }),
206
207 delete: Effect.fn(function* ({ output }) {
208 yield* zeroTrust
209 .deleteDlpEntryCustom({
210 accountId: output.accountId,
211 entryId: output.entryId,
212 })
213 .pipe(Effect.catchTag("DlpEntryNotFound", () => Effect.void));
214 }),
215 });
216
217/**
218 * Structural shape shared by the custom variant of get/create/update
219 * responses.
220 */
221type ObservedEntry = {
222 id: string;
223 enabled: boolean;
224 name: string;
225 pattern: { regex: string; validation?: "luhn" | (string & {}) | null };
226 description?: string | null;
227 profileId?: string | null;
228};
229
230/**
231 * Read an entry by id, mapping "gone" to `undefined` and narrowing to
232 * the `custom` variant of the response union.
233 */
234const observeEntry = (accountId: string, entryId: string) =>
235 zeroTrust.getDlpEntryCustom({ accountId, entryId }).pipe(
236 Effect.map((entry) =>
237 "type" in entry && entry.type === "custom"
238 ? (entry as zeroTrust.DlpEntriesGetResultCase0)
239 : undefined,
240 ),
241 Effect.catchTag("DlpEntryNotFound", () => Effect.succeed(undefined)),
242 );
243
244const createEntryName = (id: string, name: string | undefined) =>
245 Effect.gen(function* () {
246 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
247 });
248
249const encodePattern = (pattern: {
250 regex: string;
251 validation?: "luhn";
252}): { regex: string; validation?: "luhn" } => ({
253 regex: pattern.regex,
254 ...(pattern.validation !== undefined
255 ? { validation: pattern.validation }
256 : {}),
257});
258
259const toAttributes = (
260 entry: ObservedEntry,
261 accountId: string,
262): EntryAttributes => ({
263 entryId: entry.id,
264 accountId,
265 name: entry.name,
266 enabled: entry.enabled,
267 pattern: {
268 regex: entry.pattern.regex,
269 validation: entry.pattern.validation ?? undefined,
270 },
271 profileId: entry.profileId ?? undefined,
272});