1import * as organizations from "@distilled.cloud/cloudflare/organizations";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Organization.Organization" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Business profile attached to an organization. All fields are required
18 * when a profile is present — the API stores the profile as a unit.
19 */
20export interface Profile {
21 /**
22 * Street address of the business that owns the organization.
23 */
24 businessAddress: string;
25 /**
26 * Contact email of the business.
27 */
28 businessEmail: string;
29 /**
30 * Legal name of the business.
31 */
32 businessName: string;
33 /**
34 * Contact phone number of the business.
35 */
36 businessPhone: string;
37 /**
38 * Free-form external metadata (e.g. a CRM or billing reference).
39 */
40 externalMetadata: string;
41}
42
43/**
44 * Feature flags Cloudflare sets on an organization. Read-only —
45 * controlled by the organization's entitlements.
46 */
47export interface Flags {
48 /**
49 * Whether accounts may be created under this organization.
50 */
51 accountCreation: string;
52 /**
53 * Whether accounts may be deleted under this organization.
54 */
55 accountDeletion: string;
56 /**
57 * Whether accounts may be migrated into this organization.
58 */
59 accountMigration: string;
60 /**
61 * Whether accounts may be moved between organizations.
62 */
63 accountMobility: string;
64 /**
65 * Whether sub-organizations may be created under this organization.
66 */
67 subOrgCreation: string;
68}
69
70export interface Props {
71 /**
72 * Display name of the organization. Mutable in place. If omitted, a
73 * unique name is generated from the app, stage, and logical ID.
74 * @default ${app}-${stage}-${id}
75 */
76 name?: string;
77 /**
78 * ID of the parent organization, for building an organization
79 * hierarchy. Changing the parent triggers a replacement — re-parenting
80 * is gated by the organization's `accountMobility`/flags and is not
81 * supported in place.
82 */
83 parent?: string;
84 /**
85 * Business profile of the organization. Mutable in place.
86 */
87 profile?: Profile;
88}
89
90export interface Attributes {
91 /**
92 * Cloudflare-assigned identifier of the organization.
93 */
94 organizationId: string;
95 /**
96 * Display name of the organization.
97 */
98 name: string;
99 /**
100 * ISO8601 timestamp of when the organization was created.
101 */
102 createTime: string;
103 /**
104 * Who manages this organization, if it is managed by a parent entity.
105 */
106 managedBy: string | undefined;
107 /**
108 * Feature flags controlled by the organization's entitlements.
109 */
110 flags: Flags | undefined;
111 /**
112 * Parent organization, if this organization is part of a hierarchy.
113 */
114 parent: { id: string; name: string } | undefined;
115 /**
116 * Business profile of the organization, if one is set.
117 */
118 profile: Profile | undefined;
119}
120
121export type Organization = Resource<
122 TypeId,
123 Props,
124 Attributes,
125 never,
126 Providers
127>;
128
129/**
130 * A Cloudflare Organization — the hierarchical container above accounts.
131 *
132 * Organizations group accounts (and sub-organizations) under a single
133 * management umbrella; an account's `managedBy.parentOrgId` points at its
134 * owning organization. The feature is entitlement-gated: only tenant /
135 * organizations-enabled customers can create organizations — on a standard
136 * account every `/organizations` call fails with the typed `Forbidden`
137 * error.
138 *
139 * `name` and `profile` are mutable in place; changing `parent` triggers a
140 * replacement.
141 *
142 * Safety: organizations carry no ownership markers. When there is no prior
143 * state, `read` scans for an existing organization with the same name
144 * (and parent) and reports it as `Unowned`, so the engine refuses to take
145 * it over unless `--adopt` (or `adopt(true)`) is set.
146 * ### Creating an Organization
147 * **Example:** Basic organization
148 * ```typescript
149 * const org = yield* Cloudflare.Organization.Organization("Platform", {
150 * name: "acme-platform",
151 * });
152 * ```
153 *
154 * **Example:** Organization with a business profile
155 * ```typescript
156 * const org = yield* Cloudflare.Organization.Organization("Platform", {
157 * name: "acme-platform",
158 * profile: {
159 * businessName: "Acme Corp",
160 * businessEmail: "ops@acme.com",
161 * businessPhone: "+1-555-0100",
162 * businessAddress: "1 Acme Way, Springfield",
163 * externalMetadata: "crm:acct-42",
164 * },
165 * });
166 * ```
167 *
168 * ### Hierarchies
169 * **Example:** Sub-organization under a parent
170 * ```typescript
171 * const parent = yield* Cloudflare.Organization.Organization("Root", {
172 * name: "acme-root",
173 * });
174 * // Changing `parent` later replaces the sub-organization.
175 * const sub = yield* Cloudflare.Organization.Organization("Emea", {
176 * name: "acme-emea",
177 * parent: parent.organizationId,
178 * });
179 * ```
180 *
181 * @see https://developers.cloudflare.com/fundamentals/setup/manage-organizations/
182 *
183 * @resource
184 * @product Organizations
185 * @category Account & Identity
186 */
187export const Organization = Resource<Organization>(TypeId, {
188 aliases: ["Cloudflare.Organization"],
189});
190
191/**
192 * Returns true if the given value is an Organization resource.
193 */
194export const isOrganization = (value: unknown): value is Organization =>
195 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
196
197export const OrganizationProvider = () =>
198 Provider.succeed(Organization, {
199 stables: ["organizationId", "createTime"],
200
201 // Enumerate every organization reachable by the credentials. Cloudflare's
202 // `/organizations` collection is account-wide (no per-account scoping
203 // beyond the token), so there is no env scope to resolve — just paginate
204 // exhaustively and map each row to the same `Attributes` shape `read`
205 // returns. The op is entitlement-gated: on an unentitled account it
206 // rejects with the typed `Forbidden` error, which `list()` tolerates
207 // (returns `[]`) so account-wide enumeration / `nuke` never blows up.
208 list: () =>
209 organizations.listOrganizations.pages({}).pipe(
210 Stream.runCollect,
211 Effect.map((chunk) =>
212 Array.from(chunk).flatMap((page) =>
213 (page.result ?? []).map(toAttributes),
214 ),
215 ),
216 // Organizations are a Tenant/reseller feature — a regular account
217 // token gets `Forbidden` ("Authentication error"). Nothing to list.
218 Effect.catchTag("Forbidden", () => Effect.succeed([])),
219 ),
220
221 diff: Effect.fn(function* ({ olds, news, output }) {
222 // `news` may still carry unresolved plan-time expressions — defer
223 // to the engine's default update logic until everything is concrete.
224 if (!isResolved(news)) return undefined;
225 // Re-parenting is not supported in place — hierarchy moves are
226 // gated by `accountMobility`/flags, so a parent change replaces.
227 const oldParent =
228 output?.parent?.id ?? (olds?.parent as string | undefined);
229 const newParent = news.parent;
230 // `parent` is Input<string>; only compare once both are concrete.
231 if (
232 (oldParent === undefined) !== (newParent === undefined) ||
233 (typeof oldParent === "string" &&
234 typeof newParent === "string" &&
235 oldParent !== newParent)
236 ) {
237 return { action: "replace" } as const;
238 }
239 return undefined;
240 }),
241
242 read: Effect.fn(function* ({ id, output, olds }) {
243 // Owned path: refresh by our persisted organization id.
244 if (output?.organizationId) {
245 const observed = yield* getOrganization(output.organizationId);
246 return observed ? toAttributes(observed) : undefined;
247 }
248
249 // Cold read — recover from lost state by matching the deterministic
250 // physical name (+ parent). Organization names are not guaranteed
251 // unique and carry no ownership markers, so report the match as
252 // `Unowned`: the engine refuses to take it over unless `adopt` is
253 // set.
254 const name = yield* createOrganizationName(id, olds?.name);
255 const parent = olds?.parent as string | undefined;
256 const match = yield* findByName(name, parent);
257 return match ? Unowned(toAttributes(match)) : undefined;
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, output }) {
261 const name = yield* createOrganizationName(id, news.name);
262 // Inputs have been resolved to concrete strings by Plan.
263 const parent = news.parent as string | undefined;
264
265 // Observe — the organization id cached on `output` is a hint, not
266 // a guarantee: a missing organization falls through to create.
267 const observed = output?.organizationId
268 ? yield* getOrganization(output.organizationId)
269 : undefined;
270
271 if (!observed) {
272 // Ensure — greenfield (or out-of-band delete): create with the
273 // full desired body. Names are not unique on Cloudflare's side,
274 // so there is no AlreadyExists race to tolerate.
275 const created = yield* organizations.createOrganization({
276 name,
277 parent: parent !== undefined ? { id: parent } : undefined,
278 profile: news.profile,
279 });
280 return toAttributes(created);
281 }
282
283 // Sync — diff observed cloud state against desired; the update API
284 // is a PUT requiring `name`, so send the full desired shape, but
285 // skip the call entirely on a no-op. The desired profile keeps the
286 // observed one when the props omit it (the PUT would otherwise
287 // clear it).
288 const desiredProfile = news.profile ?? observed.profile ?? undefined;
289 const dirty =
290 observed.name !== name || !sameProfile(observed.profile, news.profile);
291
292 if (!dirty) {
293 return toAttributes(observed);
294 }
295
296 const updated = yield* organizations.updateOrganization({
297 organizationId: observed.id,
298 name,
299 parent:
300 observed.parent != null ? { id: observed.parent.id } : undefined,
301 profile: desiredProfile,
302 });
303 return toAttributes(updated);
304 }),
305
306 delete: Effect.fn(function* ({ output }) {
307 // Deletion is refused while the organization still manages
308 // accounts or sub-organizations — that's a genuine dependency
309 // violation, so it propagates. Already-gone is success.
310 yield* organizations
311 .deleteOrganization({ organizationId: output.organizationId })
312 .pipe(Effect.catchTag("OrganizationNotFound", () => Effect.void));
313 }),
314 });
315
316type ObservedOrganization = organizations.GetOrganizationResponse;
317
318/**
319 * Read an organization by id, mapping "gone" (`OrganizationNotFound`,
320 * HTTP 404) to `undefined`.
321 */
322const getOrganization = (organizationId: string) =>
323 organizations.getOrganization({ organizationId }).pipe(
324 Effect.map((org): ObservedOrganization | undefined => org),
325 Effect.catchTag("OrganizationNotFound", () => Effect.succeed(undefined)),
326 );
327
328/**
329 * Find an organization by exact name (and parent, when one is expected).
330 * Cloudflare's name filter narrows server-side; re-check exactly
331 * client-side. If several organizations carry the same name, pick the
332 * oldest for determinism.
333 */
334const findByName = (name: string, parent: string | undefined) =>
335 organizations.listOrganizations.items({ name: { contains: name } }).pipe(
336 Stream.runCollect,
337 Effect.map((chunk) =>
338 Array.from(chunk)
339 .filter(
340 (org) =>
341 org.name === name &&
342 (parent === undefined || org.parent?.id === parent),
343 )
344 .sort((a, b) => a.createTime.localeCompare(b.createTime))
345 .at(0),
346 ),
347 );
348
349const createOrganizationName = (id: string, name: string | undefined) =>
350 Effect.gen(function* () {
351 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
352 });
353
354/**
355 * Compare the observed profile against the desired one. An omitted
356 * desired profile is "keep whatever is there" — never a diff.
357 */
358const sameProfile = (
359 observed: Profile | null | undefined,
360 desired: Profile | undefined,
361) =>
362 desired === undefined ||
363 (observed != null &&
364 observed.businessAddress === desired.businessAddress &&
365 observed.businessEmail === desired.businessEmail &&
366 observed.businessName === desired.businessName &&
367 observed.businessPhone === desired.businessPhone &&
368 observed.externalMetadata === desired.externalMetadata);
369
370const toAttributes = (
371 org:
372 | organizations.GetOrganizationResponse
373 | organizations.CreateOrganizationResponse
374 | organizations.UpdateOrganizationResponse
375 | organizations.ListOrganizationsResponse["result"][number],
376): Attributes => ({
377 organizationId: org.id,
378 name: org.name,
379 createTime: org.createTime,
380 managedBy: org.meta.managedBy ?? undefined,
381 flags: org.meta.tenantFlags ?? undefined,
382 parent: org.parent ?? undefined,
383 profile: org.profile ?? undefined,
384});