1import * as accounts from "@distilled.cloud/cloudflare/accounts";
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 type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Account.Account" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * The kind of Cloudflare account. Cannot be changed after creation.
17 */
18export type AccountType = "standard" | "enterprise";
19
20export interface AccountProps {
21 /**
22 * Account name (display name). Mutable in place. If omitted, a unique
23 * name is generated from the app, stage, and logical ID.
24 * @default ${app}-${stage}-${id}
25 */
26 name?: string;
27 /**
28 * The kind of account to create. Cannot be changed after creation —
29 * Cloudflare rejects type changes (`UpdateAccountTypeNotSupported`), so
30 * updating this property triggers a replacement.
31 * @default "standard"
32 */
33 type?: AccountType;
34 /**
35 * Tenant unit to create the account under. Only meaningful for tenant /
36 * partner credentials; defaults to the tenant's root unit. Create-only —
37 * updating this property triggers a replacement.
38 *
39 * @see https://developers.cloudflare.com/tenant/how-to/manage-accounts/
40 */
41 unit?: {
42 /**
43 * The id of the tenant unit to create the account on.
44 */
45 id?: string;
46 };
47 /**
48 * Abuse contact email address for the account
49 * (`settings.abuse_contact_email`). Mutable in place. When omitted, the
50 * setting is left unmanaged (an existing value is not cleared).
51 */
52 abuseContactEmail?: string;
53 /**
54 * Whether membership in this account requires that two-factor
55 * authentication is enabled (`settings.enforce_twofactor`). Mutable in
56 * place. When omitted, the setting is left unmanaged.
57 * @default false
58 */
59 enforceTwofactor?: boolean;
60}
61
62export interface AccountAttributes {
63 /**
64 * Account identifier tag assigned by Cloudflare.
65 */
66 accountId: string;
67 /**
68 * Account name (display name).
69 */
70 name: string;
71 /**
72 * The kind of account.
73 */
74 type: AccountType;
75 /**
76 * Timestamp for the creation of the account.
77 */
78 createdOn: string | undefined;
79 /**
80 * Id of the tenant organization the account is managed by, if any.
81 */
82 parentOrgId: string | undefined;
83 /**
84 * Name of the tenant organization the account is managed by, if any.
85 */
86 parentOrgName: string | undefined;
87 /**
88 * Abuse contact email address configured on the account, if any.
89 */
90 abuseContactEmail: string | undefined;
91 /**
92 * Whether membership in the account requires two-factor authentication.
93 */
94 enforceTwofactor: boolean;
95}
96
97export type Account = Resource<
98 TypeId,
99 AccountProps,
100 AccountAttributes,
101 never,
102 Providers
103>;
104
105/**
106 * A Cloudflare account (subaccount), for tenant / partner platforms that
107 * provision an account per customer.
108 *
109 * Creating accounts (`POST /accounts`) is restricted to credentials with the
110 * tenant entitlement — a standard user receives the typed
111 * `AccountCreationForbidden` error (Cloudflare error code 1002). The `name`
112 * and account settings are mutable in place; `type` and the tenant `unit`
113 * are create-only and trigger a replacement. Deleting the resource queues
114 * the account for deletion (also tenant-gated).
115 *
116 * The account's physical identity is the Cloudflare-assigned `accountId`.
117 * Account names are not unique, so there is no find-by-name fallback: if
118 * state is lost, the account is treated as missing rather than guessed at.
119 * ### Creating an account
120 * **Example:** Standard subaccount with a generated name
121 * ```typescript
122 * const account = yield* Cloudflare.Account.Account("CustomerAccount", {});
123 * ```
124 *
125 * **Example:** Subaccount on a specific tenant unit
126 * ```typescript
127 * const account = yield* Cloudflare.Account.Account("CustomerAccount", {
128 * name: "Customer: ACME Inc",
129 * unit: { id: tenantUnitId },
130 * });
131 * ```
132 *
133 * ### Account settings
134 * **Example:** Enforce two-factor authentication for all members
135 * ```typescript
136 * const account = yield* Cloudflare.Account.Account("CustomerAccount", {
137 * name: "Customer: ACME Inc",
138 * enforceTwofactor: true,
139 * abuseContactEmail: "abuse@acme.example",
140 * });
141 * ```
142 *
143 * @see https://developers.cloudflare.com/tenant/how-to/manage-accounts/
144 *
145 * @resource
146 * @product Accounts
147 * @category Account & Identity
148 */
149export const Account = Resource<Account>(TypeId, {
150 aliases: ["Cloudflare.Account"],
151});
152
153/**
154 * Returns true if the given value is an Account resource.
155 */
156export const isAccount = (value: unknown): value is Account =>
157 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
158
159export const AccountProvider = () =>
160 Provider.succeed(Account, {
161 stables: ["accountId", "type", "createdOn", "parentOrgId", "parentOrgName"],
162
163 diff: Effect.fn(function* ({ olds, news, output }) {
164 if (!isResolved(news)) return undefined;
165 // The account type cannot be changed after creation — Cloudflare
166 // rejects it with `UpdateAccountTypeNotSupported`.
167 const oldType = output?.type ?? olds?.type ?? "standard";
168 if ((news.type ?? "standard") !== oldType) {
169 return { action: "replace" } as const;
170 }
171 // The tenant unit is a create-only placement decision.
172 if ((olds?.unit?.id ?? undefined) !== (news.unit?.id ?? undefined)) {
173 return { action: "replace" } as const;
174 }
175 return undefined;
176 }),
177
178 list: () =>
179 // `listAccounts` (GET /accounts) enumerates every account the API
180 // token can access — no scope input required. The list items carry
181 // the full account shape (settings + managedBy), so each maps
182 // directly to the same `Attributes` `read` produces with no
183 // per-item hydration. Paginate exhaustively.
184 accounts.listAccounts.pages({}).pipe(
185 Stream.runCollect,
186 Effect.map((chunk) =>
187 Array.from(chunk).flatMap((page) =>
188 (page.result ?? []).map((account) => toAttributes(account)),
189 ),
190 ),
191 ),
192
193 read: Effect.fn(function* ({ output }) {
194 // The physical identity is the Cloudflare-assigned account id —
195 // names are not unique, so there is no cold find-by-name fallback.
196 if (!output?.accountId) return undefined;
197 const observed = yield* getAccount(output.accountId);
198 return observed ? toAttributes(observed) : undefined;
199 }),
200
201 reconcile: Effect.fn(function* ({ id, news, output }) {
202 const name =
203 news.name ?? (yield* createPhysicalName({ id, lowercase: true }));
204
205 // 1. Observe — the account id cached on `output` is a hint, not a
206 // guarantee: a deleted account falls through to create.
207 let observed = output?.accountId
208 ? yield* getAccount(output.accountId)
209 : undefined;
210
211 // 2. Ensure — create when missing. Names are not unique on
212 // Cloudflare's side, so there is no AlreadyExists race to
213 // tolerate. Requires the tenant entitlement: a standard user
214 // surfaces the typed `AccountCreationForbidden` (code 1002).
215 if (!observed) {
216 observed = yield* accounts.createAccount({
217 name,
218 type: news.type,
219 unit: news.unit,
220 });
221 }
222
223 // 3. Sync — diff observed cloud state against desired; skip the PUT
224 // entirely on a no-op. Settings props left undefined are
225 // unmanaged: the observed value is kept as-is.
226 const settings = observed.settings ?? undefined;
227 const nameDirty = observed.name !== name;
228 const abuseDirty =
229 news.abuseContactEmail !== undefined &&
230 (settings?.abuseContactEmail ?? undefined) !== news.abuseContactEmail;
231 const twofactorDirty =
232 news.enforceTwofactor !== undefined &&
233 (settings?.enforceTwofactor ?? false) !== news.enforceTwofactor;
234
235 if (nameDirty || abuseDirty || twofactorDirty) {
236 observed = yield* accounts.updateAccount({
237 accountId: observed.id,
238 id: observed.id,
239 name,
240 settings:
241 abuseDirty || twofactorDirty
242 ? {
243 abuseContactEmail: news.abuseContactEmail,
244 enforceTwofactor: news.enforceTwofactor,
245 }
246 : undefined,
247 });
248 }
249
250 return toAttributes(observed);
251 }),
252
253 delete: Effect.fn(function* ({ output }) {
254 // Account deletion is queued / asynchronous on Cloudflare's side —
255 // a successful DELETE means "pending deletion", which is success for
256 // us. An already-gone (or already-pending) account surfaces as
257 // `InvalidRoute` (code 7003): that's success too.
258 yield* accounts
259 .deleteAccount({ accountId: output.accountId })
260 .pipe(Effect.catchTag("InvalidRoute", () => Effect.void));
261 }),
262 });
263
264type ObservedAccount =
265 | accounts.GetAccountResponse
266 | accounts.CreateAccountResponse
267 | accounts.UpdateAccountResponse
268 | accounts.ListAccountsResponse["result"][number];
269
270/**
271 * Read an account by id, mapping "gone" to `undefined`. A missing (or
272 * deletion-pending) account surfaces as `InvalidRoute` (Cloudflare error
273 * code 7003).
274 */
275const getAccount = (accountId: string) =>
276 accounts.getAccount({ accountId }).pipe(
277 Effect.map((account): ObservedAccount | undefined => account),
278 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
279 );
280
281const toAttributes = (account: ObservedAccount): AccountAttributes => ({
282 accountId: account.id,
283 name: account.name,
284 // Distilled widens generated string enums to open unions (`string & {}`).
285 type: account.type as AccountType,
286 createdOn: account.createdOn ?? undefined,
287 parentOrgId: account.managedBy?.parentOrgId ?? undefined,
288 parentOrgName: account.managedBy?.parentOrgName ?? undefined,
289 abuseContactEmail: account.settings?.abuseContactEmail ?? undefined,
290 enforceTwofactor: account.settings?.enforceTwofactor ?? false,
291});