1import * as logs from "@distilled.cloud/cloudflare/logs";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10
11const TypeId = "Cloudflare.Logs.CmbConfig" as const;
12type TypeId = typeof TypeId;
13
14export type CmbConfigProps = {
15 /**
16 * The Cloudflare account whose Customer Metadata Boundary (CMB) log
17 * configuration is managed. The config is an account-level singleton, so
18 * the account is the resource's identity — changing it triggers a
19 * replacement.
20 * @default the account from the active Cloudflare profile
21 */
22 accountId?: string;
23 /**
24 * Name of the region log data is restricted to (e.g. `"eu"`).
25 *
26 * Changing the CMB region affects where ALL logs for the account are
27 * stored and processed — treat this as a destructive, account-wide
28 * setting.
29 */
30 regions?: string;
31 /**
32 * Whether log data may be accessed from outside the configured region.
33 * @default false
34 */
35 allowOutOfRegionAccess?: boolean;
36};
37
38export type CmbConfigAttributes = {
39 /** The Cloudflare account the CMB config belongs to. */
40 accountId: string;
41 /** Name of the region log data is restricted to. */
42 regions: string | undefined;
43 /** Whether log data may be accessed from outside the configured region. */
44 allowOutOfRegionAccess: boolean | undefined;
45};
46
47export type CmbConfig = Resource<
48 TypeId,
49 CmbConfigProps,
50 CmbConfigAttributes,
51 never,
52 Providers
53>;
54
55/**
56 * The account-level Customer Metadata Boundary (CMB) configuration for
57 * Cloudflare Logs (`/accounts/{account_id}/logs/control/cmb/config`).
58 *
59 * The CMB config is a true account singleton with PUT/DELETE semantics: the
60 * POST endpoint is a full upsert, and DELETE removes the configuration
61 * entirely (an account with no CMB config reads back as empty). Identity is
62 * the account itself.
63 *
64 * CMB is part of Cloudflare's Data Localization Suite and requires an
65 * Enterprise plan — on unentitled accounts every operation fails with the
66 * typed `LogsControlNotAuthorized` error.
67 *
68 * :::warning
69 * Changing the CMB region changes where ALL logs for the account are stored
70 * and processed, and deleting the config lifts the boundary. Handle with
71 * care in production accounts.
72 * :::
73 * ### Restricting logs to a region
74 * **Example:** Keep all account logs in the EU
75 * ```typescript
76 * const cmb = yield* Cloudflare.LogsControl.CmbConfig("EuLogs", {
77 * regions: "eu",
78 * });
79 * ```
80 *
81 * **Example:** Allow out-of-region access
82 * ```typescript
83 * const cmb = yield* Cloudflare.LogsControl.CmbConfig("EuLogs", {
84 * regions: "eu",
85 * allowOutOfRegionAccess: true,
86 * });
87 * ```
88 *
89 * @see https://developers.cloudflare.com/data-localization/metadata-boundary/
90 *
91 * @resource
92 * @product Logs
93 * @category Observability & Analytics
94 */
95export const CmbConfig = Resource<CmbConfig>(TypeId);
96
97/**
98 * Returns true if the given value is a CmbConfig resource.
99 */
100export const isCmbConfig = (value: unknown): value is CmbConfig =>
101 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
102
103export const CmbConfigProvider = () =>
104 Provider.succeed(CmbConfig, {
105 stables: ["accountId"],
106
107 diff: Effect.fn(function* ({ news, output }) {
108 if (!isResolved(news)) return undefined;
109 const { accountId } = yield* yield* CloudflareEnvironment;
110 const newAccountId = news.accountId ?? accountId;
111 // The account is the singleton's identity.
112 if (output !== undefined && output.accountId !== newAccountId) {
113 return { action: "replace" } as const;
114 }
115 return undefined;
116 }),
117
118 read: Effect.fn(function* ({ output, olds }) {
119 const { accountId: envAccountId } = yield* yield* CloudflareEnvironment;
120 const accountId =
121 output?.accountId ??
122 (typeof olds?.accountId === "string" ? olds.accountId : envAccountId);
123 const observed = yield* getCmbConfig(accountId);
124 // Unconfigured account — the singleton does not currently exist.
125 if (observed === undefined) return undefined;
126 // The config is an account singleton — there is no foreign instance
127 // to protect, so a cold read adopts freely.
128 return toAttributes(accountId, observed);
129 }),
130
131 // Account singleton: there is no account-wide collection API, only the
132 // single `/logs/control/cmb/config` GET. Mirror `read` exactly — return a
133 // one-element array when the config is set, `[]` when the account is
134 // unconfigured.
135 list: Effect.fn(function* () {
136 const { accountId } = yield* yield* CloudflareEnvironment;
137 const observed = yield* getCmbConfig(accountId);
138 return observed === undefined ? [] : [toAttributes(accountId, observed)];
139 }),
140
141 reconcile: Effect.fn(function* ({ news }) {
142 const { accountId: envAccountId } = yield* yield* CloudflareEnvironment;
143 // Inputs were resolved to concrete values by Plan.
144 const accountId = (news.accountId as string | undefined) ?? envAccountId;
145
146 // Observe — read the live config (undefined = unconfigured).
147 const observed = yield* getCmbConfig(accountId);
148
149 // Sync — POST is a full upsert; skip the call when the observed
150 // config already matches the desired one.
151 const desiredAllowOutOfRegionAccess = news.allowOutOfRegionAccess;
152 const inSync =
153 observed !== undefined &&
154 (observed.regions ?? undefined) === news.regions &&
155 (desiredAllowOutOfRegionAccess === undefined ||
156 (observed.allowOutOfRegionAccess ?? false) ===
157 desiredAllowOutOfRegionAccess);
158 if (inSync) {
159 return toAttributes(accountId, observed);
160 }
161
162 const created = yield* logs.createControlCmbConfig({
163 accountId,
164 regions: news.regions,
165 allowOutOfRegionAccess: news.allowOutOfRegionAccess,
166 });
167 return toAttributes(accountId, created);
168 }),
169
170 delete: Effect.fn(function* ({ output }) {
171 // Observe — already unconfigured (or gone with the account) means
172 // there is nothing to delete; re-delete after a crashed run is a
173 // no-op.
174 const observed = yield* getCmbConfig(output.accountId).pipe(
175 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
176 );
177 if (observed === undefined) return;
178 yield* logs
179 .deleteControlCmbConfig({ accountId: output.accountId })
180 .pipe(
181 Effect.catchTag(
182 ["CmbConfigNotFound", "InvalidRoute"],
183 () => Effect.void,
184 ),
185 );
186 }),
187 });
188
189/**
190 * Read the CMB config, normalizing "not configured" to `undefined`. An
191 * unconfigured account answers with `result: null` (decoded as an empty
192 * struct) or a 404 (`CmbConfigNotFound`).
193 */
194const getCmbConfig = (accountId: string) =>
195 logs.getControlCmbConfig({ accountId }).pipe(
196 Effect.map((config) =>
197 config.regions === undefined || config.regions === null
198 ? undefined
199 : config,
200 ),
201 // Accounts without the Compliance/CMB entitlement get
202 // `LogsControlNotAuthorized` — treat as unconfigured (nothing to manage).
203 Effect.catchTag(["CmbConfigNotFound", "LogsControlNotAuthorized"], () =>
204 Effect.succeed(undefined),
205 ),
206 );
207
208const toAttributes = (
209 accountId: string,
210 config:
211 | logs.GetControlCmbConfigResponse
212 | logs.CreateControlCmbConfigResponse,
213): CmbConfigAttributes => ({
214 accountId,
215 regions: config.regions ?? undefined,
216 allowOutOfRegionAccess: config.allowOutOfRegionAccess ?? undefined,
217});