| 1 | import * as accounts from "@distilled.cloud/cloudflare/accounts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | const TypeId = "Cloudflare.Account.Account" as const; |
| 13 | type TypeId = typeof TypeId; |
| 14 | |
| 15 | /** |
| 16 | * The kind of Cloudflare account. Cannot be changed after creation. |
| 17 | */ |
| 18 | export type AccountType = "standard" | "enterprise"; |
| 19 | |
| 20 | export 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 | |
| 62 | export 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 | |
| 97 | export 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 | */ |
| 149 | export const Account = Resource<Account>(TypeId, { |
| 150 | aliases: ["Cloudflare.Account"], |
| 151 | }); |
| 152 | |
| 153 | /** |
| 154 | * Returns true if the given value is an Account resource. |
| 155 | */ |
| 156 | export const isAccount = (value: unknown): value is Account => |
| 157 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 158 | |
| 159 | export 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 | |
| 264 | type 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 | */ |
| 275 | const getAccount = (accountId: string) => |
| 276 | accounts.getAccount({ accountId }).pipe( |
| 277 | Effect.map((account): ObservedAccount | undefined => account), |
| 278 | Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)), |
| 279 | ); |
| 280 | |
| 281 | const 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 | }); |