1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9
10const TypeId = "Cloudflare.Gateway.Logging" as const;
11type TypeId = typeof TypeId;
12
13/**
14 * Per-rule-type logging toggles.
15 */
16export interface LoggingRuleSettings {
17 /** Log all requests of this type, regardless of rule matches. */
18 logAll?: boolean;
19 /** Log only requests blocked by a rule. */
20 logBlocks?: boolean;
21}
22
23/**
24 * A snapshot of the account's Gateway logging settings as observed on
25 * Cloudflare. Captured before Alchemy first writes the singleton and
26 * restored on destroy.
27 */
28export interface LoggingSnapshot {
29 /** Whether PII is redacted from activity logs. */
30 redactPii?: boolean;
31 /** Logging settings for DNS queries. */
32 dns?: LoggingRuleSettings;
33 /** Logging settings for HTTP requests. */
34 http?: LoggingRuleSettings;
35 /** Logging settings for layer-4 (network) sessions. */
36 l4?: LoggingRuleSettings;
37}
38
39export interface LoggingProps {
40 /**
41 * Redact personally identifiable information from activity logging
42 * (PII fields include source IP, user email, user ID, device ID, URL,
43 * referrer, and user agent).
44 */
45 redactPii?: boolean;
46 /**
47 * Logging settings per rule type. Only the rule types (and fields) you
48 * declare are converged; everything else keeps its current value.
49 */
50 settingsByRuleType?: {
51 /** Logging settings for DNS queries. */
52 dns?: LoggingRuleSettings;
53 /** Logging settings for HTTP requests. */
54 http?: LoggingRuleSettings;
55 /** Logging settings for layer-4 (network) sessions. */
56 l4?: LoggingRuleSettings;
57 };
58}
59
60export type LoggingAttributes = LoggingSnapshot & {
61 /** Account that owns the Gateway logging singleton. */
62 accountId: string;
63 /**
64 * The logging settings the account had before Alchemy first wrote
65 * them. Restored (via PUT) on destroy, so deleting the resource puts
66 * the account back the way it was found.
67 */
68 initialSettings: LoggingSnapshot;
69};
70
71export type Logging = Resource<
72 TypeId,
73 LoggingProps,
74 LoggingAttributes,
75 never,
76 Providers
77>;
78
79/**
80 * Manages the **singleton** Cloudflare Zero Trust **Gateway logging
81 * settings** for an account (`/accounts/{accountId}/gateway/logging`) —
82 * PII redaction and per-rule-type (DNS / HTTP / L4) activity-log toggles.
83 *
84 * The singleton always exists, so reconcile converges only the fields you
85 * declare (merging them over the observed state before the PUT, since the
86 * API is PUT-only). The pre-management snapshot is captured on first touch
87 * and restored on destroy (capture-and-restore).
88 * ### Managing logging settings
89 * **Example:** Log everything, keep PII
90 * ```typescript
91 * yield* Cloudflare.Gateway.Logging("Logging", {
92 * redactPii: false,
93 * settingsByRuleType: {
94 * dns: { logAll: true, logBlocks: true },
95 * http: { logAll: true, logBlocks: true },
96 * l4: { logAll: true, logBlocks: true },
97 * },
98 * });
99 * ```
100 *
101 * **Example:** Only log blocked DNS queries, redacting PII
102 * ```typescript
103 * yield* Cloudflare.Gateway.Logging("Logging", {
104 * redactPii: true,
105 * settingsByRuleType: {
106 * dns: { logAll: false, logBlocks: true },
107 * },
108 * });
109 * ```
110 *
111 * @see https://developers.cloudflare.com/cloudflare-one/insights/logs/gateway-logs/
112 *
113 * @resource
114 * @product Gateway
115 * @category Cloudflare One (Zero Trust)
116 */
117export const Logging = Resource<Logging>(TypeId);
118
119/**
120 * Returns true if the given value is a Logging resource.
121 */
122export const isLogging = (value: unknown): value is Logging =>
123 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
124
125export const LoggingProvider = () =>
126 Provider.succeed(Logging, {
127 nuke: { singleton: true },
128 stables: ["accountId", "initialSettings"],
129
130 list: Effect.fn(function* () {
131 const { accountId } = yield* yield* CloudflareEnvironment;
132 // Account singleton — there is no enumeration API; the Gateway
133 // logging settings object always exists for the account. Mirror
134 // `read`: observe the single instance and return it as a
135 // one-element array (its observed snapshot is its own
136 // `initialSettings`, since nothing is being managed).
137 const observed = yield* observeLogging(accountId);
138 return [toAttributes(accountId, observed, observed)];
139 }),
140
141 read: Effect.fn(function* ({ output }) {
142 const { accountId } = yield* yield* CloudflareEnvironment;
143 const acct = output?.accountId ?? accountId;
144 const observed = yield* observeLogging(acct);
145 // The singleton always exists with account defaults — there is
146 // nothing to "own", so a cold read adopts freely. The observed
147 // snapshot at adoption time becomes the restore target.
148 const initialSettings = output?.initialSettings ?? observed;
149 return toAttributes(acct, observed, initialSettings);
150 }),
151
152 reconcile: Effect.fn(function* ({ news, output }) {
153 const { accountId } = yield* yield* CloudflareEnvironment;
154
155 // 1. Observe — the singleton always exists; read its live state.
156 const observed = yield* observeLogging(accountId);
157
158 // 2. Capture — the pre-management snapshot, restored on destroy.
159 const initialSettings = output?.initialSettings ?? observed;
160
161 // 3. Sync — merge the declared fields over the observed state and
162 // PUT only when something actually changes (the API has no
163 // PATCH, so the merge keeps undeclared fields at their current
164 // values).
165 const desired = mergeSnapshot(observed, {
166 redactPii: news.redactPii,
167 dns: news.settingsByRuleType?.dns,
168 http: news.settingsByRuleType?.http,
169 l4: news.settingsByRuleType?.l4,
170 });
171 if (sameSnapshot(observed, desired)) {
172 return toAttributes(accountId, observed, initialSettings);
173 }
174 yield* putLogging(accountId, desired);
175
176 // 4. Return — re-read so attrs reflect post-sync truth.
177 const final = yield* observeLogging(accountId);
178 return toAttributes(accountId, final, initialSettings);
179 }),
180
181 delete: Effect.fn(function* ({ output }) {
182 const { accountId, initialSettings } = output;
183 // Observe — skip the restore when the account already matches the
184 // captured snapshot (idempotent re-delete after a crashed run).
185 const observed = yield* observeLogging(accountId);
186 if (sameSnapshot(observed, initialSettings)) return;
187 yield* putLogging(accountId, initialSettings);
188 }),
189 });
190
191/**
192 * Read the live logging settings, normalized to a `null`-free snapshot.
193 */
194const observeLogging = (accountId: string) =>
195 zeroTrust.getGatewayLogging({ accountId }).pipe(
196 Effect.map((s): LoggingSnapshot => {
197 const snapshot: LoggingSnapshot = {};
198 if (s.redactPii != null) snapshot.redactPii = s.redactPii;
199 const byType = s.settingsByRuleType ?? undefined;
200 for (const key of RULE_TYPES) {
201 const block = byType?.[key];
202 if (block == null) continue;
203 const rule: LoggingRuleSettings = {};
204 if (block.logAll != null) rule.logAll = block.logAll;
205 if (block.logBlocks != null) rule.logBlocks = block.logBlocks;
206 snapshot[key] = rule;
207 }
208 return snapshot;
209 }),
210 );
211
212const putLogging = (accountId: string, snapshot: LoggingSnapshot) =>
213 zeroTrust.putGatewayLogging({
214 accountId,
215 ...(snapshot.redactPii !== undefined
216 ? { redactPii: snapshot.redactPii }
217 : {}),
218 settingsByRuleType: {
219 ...(snapshot.dns !== undefined ? { dns: snapshot.dns } : {}),
220 ...(snapshot.http !== undefined ? { http: snapshot.http } : {}),
221 ...(snapshot.l4 !== undefined ? { l4: snapshot.l4 } : {}),
222 },
223 });
224
225const RULE_TYPES = ["dns", "http", "l4"] as const;
226
227/**
228 * Overlay the declared (non-undefined) fields onto the observed snapshot,
229 * merging per-rule-type blocks field-by-field.
230 */
231const mergeSnapshot = (
232 observed: LoggingSnapshot,
233 declared: LoggingSnapshot,
234): LoggingSnapshot => {
235 const merged: LoggingSnapshot =
236 observed.redactPii !== undefined ? { redactPii: observed.redactPii } : {};
237 if (declared.redactPii !== undefined) merged.redactPii = declared.redactPii;
238 for (const key of RULE_TYPES) {
239 const base = observed[key];
240 const over = declared[key];
241 if (base === undefined && over === undefined) continue;
242 const rule: LoggingRuleSettings = { ...base };
243 if (over?.logAll !== undefined) rule.logAll = over.logAll;
244 if (over?.logBlocks !== undefined) rule.logBlocks = over.logBlocks;
245 merged[key] = rule;
246 }
247 return merged;
248};
249
250const sameSnapshot = (a: LoggingSnapshot, b: LoggingSnapshot): boolean =>
251 a.redactPii === b.redactPii &&
252 RULE_TYPES.every(
253 (key) =>
254 (a[key]?.logAll ?? undefined) === (b[key]?.logAll ?? undefined) &&
255 (a[key]?.logBlocks ?? undefined) === (b[key]?.logBlocks ?? undefined),
256 );
257
258const toAttributes = (
259 accountId: string,
260 observed: LoggingSnapshot,
261 initialSettings: LoggingSnapshot,
262): LoggingAttributes => ({
263 ...observed,
264 accountId,
265 initialSettings,
266});