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 { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Account.Member" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * A member's invitation status in the account. New invites start as
17 * `pending` and become `accepted` when the invitee accepts.
18 */
19export type MemberStatus = "accepted" | "pending";
20
21/**
22 * A scoped access policy attached to an account membership. Policies are an
23 * Enterprise alternative to legacy `roles` — they grant a permission group
24 * over a resource group with an explicit allow/deny.
25 */
26export interface MemberPolicy {
27 /**
28 * Whether the policy allows or denies the permission groups on the
29 * resource groups.
30 */
31 access: "allow" | "deny";
32 /**
33 * Permission groups granted by this policy.
34 */
35 permissionGroups: { id: string }[];
36 /**
37 * Resource groups this policy applies to.
38 */
39 resourceGroups: { id: string }[];
40}
41
42export interface MemberProps {
43 /**
44 * The contact email address of the user to invite. The email is the
45 * identity of the membership — there is no API to change it, so updating
46 * this property triggers a replacement (a new invite is sent and the old
47 * membership is removed).
48 */
49 email: string;
50 /**
51 * IDs of legacy account roles to assign to the member. Mutable — role
52 * changes are applied in place via `PUT`. Role IDs can be looked up by
53 * name with {@link findAccountRoleByName}.
54 *
55 * Exactly one of `roles` or `policies` should be provided.
56 */
57 roles?: string[];
58 /**
59 * Scoped access policies to attach to the member (Enterprise feature).
60 * Mutable — policy changes are applied in place via `PUT`.
61 *
62 * Exactly one of `roles` or `policies` should be provided.
63 */
64 policies?: MemberPolicy[];
65 /**
66 * Status of the member invitation. Only `pending` can be requested when
67 * inviting; the invitee flips it to `accepted` by accepting. Changing an
68 * already-`accepted` membership back to `pending` triggers a replacement
69 * (the member is removed and re-invited).
70 * @default "pending"
71 */
72 status?: MemberStatus;
73}
74
75export interface MemberAttributes {
76 /**
77 * Membership identifier tag assigned by Cloudflare.
78 */
79 memberId: string;
80 /**
81 * The Cloudflare account the membership belongs to.
82 */
83 accountId: string;
84 /**
85 * The contact email address of the member.
86 */
87 email: string;
88 /**
89 * The member's invitation status (`pending` until the invite is
90 * accepted).
91 */
92 status: MemberStatus;
93 /**
94 * Roles assigned to the member, resolved to `{ id, name }` pairs.
95 */
96 roles: { id: string; name: string }[];
97 /**
98 * Scoped access policies attached to the member, or `undefined` when the
99 * membership uses legacy roles only.
100 */
101 policies: MemberPolicy[] | undefined;
102 /**
103 * The user id behind the membership, if the invitee already has a
104 * Cloudflare user.
105 */
106 userId: string | undefined;
107}
108
109export type Member = Resource<
110 TypeId,
111 MemberProps,
112 MemberAttributes,
113 never,
114 Providers
115>;
116
117/**
118 * A member of a Cloudflare account — an invitation for a user (by email) to
119 * join the account with a set of roles or scoped policies.
120 *
121 * The membership's identity is its `email`: there is no API to change the
122 * address, so updating `email` triggers a replacement (a fresh invite). The
123 * assigned `roles`/`policies` are mutable in place. New invites stay
124 * `pending` until the invitee accepts; deleting the resource cancels a
125 * pending invite or removes an accepted member.
126 *
127 * Safety: memberships carry no ownership markers. When there is no prior
128 * state, `read` scans the account for an existing membership with the same
129 * email and reports it as `Unowned`, so the engine refuses to take it over
130 * unless `--adopt` (or `adopt(true)`) is set.
131 * ### Inviting a member
132 * **Example:** Invite with a role looked up by name
133 * ```typescript
134 * const role = yield* Cloudflare.Account.findAccountRoleByName(
135 * accountId,
136 * "Administrator Read Only",
137 * );
138 *
139 * yield* Cloudflare.Account.Member("Auditor", {
140 * email: "auditor@example.com",
141 * roles: [role!.id],
142 * });
143 * ```
144 *
145 * ### Changing roles
146 * **Example:** Swap the member's role in place
147 * ```typescript
148 * // Same email — the membership is updated, not replaced.
149 * yield* Cloudflare.Account.Member("Auditor", {
150 * email: "auditor@example.com",
151 * roles: [adminRole.id],
152 * });
153 * ```
154 *
155 * ### Scoped policies (Enterprise)
156 * **Example:** Invite with a scoped policy instead of roles
157 * ```typescript
158 * yield* Cloudflare.Account.Member("ScopedOperator", {
159 * email: "operator@example.com",
160 * policies: [{
161 * access: "allow",
162 * permissionGroups: [{ id: permissionGroupId }],
163 * resourceGroups: [{ id: resourceGroupId }],
164 * }],
165 * });
166 * ```
167 *
168 * @see https://developers.cloudflare.com/fundamentals/manage-members/
169 *
170 * @resource
171 * @product Accounts
172 * @category Account & Identity
173 */
174export const Member = Resource<Member>(TypeId);
175
176/**
177 * Returns true if the given value is an Member resource.
178 */
179export const isMember = (value: unknown): value is Member =>
180 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
181
182export const MemberProvider = () =>
183 Provider.succeed(Member, {
184 stables: ["memberId", "accountId", "email", "userId"],
185
186 list: Effect.fn(function* () {
187 const { accountId } = yield* yield* CloudflareEnvironment;
188 // Exhaustively paginate the account membership list to collect every
189 // member id, then re-read each membership through `getMember` so each
190 // element matches the EXACT shape `read`/`reconcile` produce. A
191 // membership that vanishes between the list and the per-item read
192 // (typed `MemberNotFound`, Cloudflare code 1003) is dropped.
193 const ids = yield* accounts.listMembers.pages({ accountId }).pipe(
194 Stream.runCollect,
195 Effect.map((chunk) =>
196 Array.from(chunk).flatMap((page) =>
197 (page.result ?? [])
198 .map((member) => member.id)
199 .filter((id): id is string => id != null),
200 ),
201 ),
202 );
203 const rows = yield* Effect.forEach(
204 ids,
205 (memberId) =>
206 getMember(accountId, memberId).pipe(
207 Effect.map((member) =>
208 member ? toAttributes(member, accountId) : undefined,
209 ),
210 ),
211 { concurrency: 10 },
212 );
213 return rows.filter((row): row is MemberAttributes => row !== undefined);
214 }),
215
216 diff: Effect.fn(function* ({ olds = {}, news }) {
217 const o = olds as Partial<MemberProps>;
218 const n = news as MemberProps;
219 // No prior props to compare against — let the engine decide.
220 if (o.email === undefined) return undefined;
221 // The email is the identity of the invite — it cannot be changed.
222 if (!sameEmail(o.email, n.email)) {
223 return { action: "replace" } as const;
224 }
225 // Per the API, demoting an accepted membership back to `pending`
226 // requires removing and re-inviting the member.
227 if (o.status === "accepted" && n.status === "pending") {
228 return { action: "replace" } as const;
229 }
230 return undefined;
231 }),
232
233 read: Effect.fn(function* ({ output, olds }) {
234 const { accountId } = yield* yield* CloudflareEnvironment;
235 const acct = output?.accountId ?? accountId;
236
237 // Owned path: refresh by our persisted membership id.
238 if (output?.memberId) {
239 const observed = yield* getMember(acct, output.memberId);
240 if (observed) return toAttributes(observed, acct);
241 }
242
243 // Adoption path: a membership for this email may already exist.
244 // Memberships carry no ownership markers, so we cannot prove we
245 // created it — brand it `Unowned` so the engine refuses to take
246 // over unless `adopt` is set.
247 const email = output?.email ?? (olds?.email as string | undefined);
248 if (email) {
249 const observed = yield* findByEmail(acct, email);
250 if (observed) return Unowned(toAttributes(observed, acct));
251 }
252 return undefined;
253 }),
254
255 reconcile: Effect.fn(function* ({ news, output }) {
256 const { accountId } = yield* yield* CloudflareEnvironment;
257 // Inputs have been resolved to concrete strings by Plan.
258 const desiredRoles = news.roles as string[] | undefined;
259
260 // 1. Observe — the membership id cached on `output` is a hint, not
261 // a guarantee: a missing member falls through to the email scan
262 // and then to create.
263 let observed = output?.memberId
264 ? yield* getMember(accountId, output.memberId)
265 : undefined;
266
267 // 2. Fall back to scanning the account for the email. Ownership has
268 // already been verified upstream — `read` reports existing
269 // memberships as `Unowned` and the engine gates takeover behind
270 // the adopt policy before reconcile ever runs.
271 if (!observed) {
272 observed = yield* findByEmail(accountId, news.email);
273 }
274
275 // 3. Ensure — invite when missing. A concurrent (or orphaned) invite
276 // for the same email surfaces either as a generic validation error
277 // or as the typed `AccountMemberAlreadyExists` (Cloudflare answers
278 // the duplicate with HTTP 400 "Account member already exists for
279 // email address"): in both cases converge by re-scanning for the
280 // membership that won the race and adopting it.
281 if (!observed) {
282 const created = yield* accounts
283 .createMember({
284 accountId,
285 email: news.email,
286 roles: desiredRoles,
287 policies: news.policies,
288 status: news.status,
289 })
290 .pipe(
291 Effect.catchTag(
292 ["ValidationError", "AccountMemberAlreadyExists"],
293 (error) =>
294 findByEmail(accountId, news.email).pipe(
295 Effect.flatMap((existing) =>
296 existing ? Effect.succeed(existing) : Effect.fail(error),
297 ),
298 ),
299 ),
300 );
301 observed = created;
302 }
303
304 // 4. Sync — diff the observed roles/policies against desired; skip
305 // the PUT entirely on a no-op. `status` is not synced: only the
306 // invitee can accept, and accepted -> pending is a replacement
307 // handled by diff.
308 const memberId = observed.id ?? "";
309 const rolesDirty =
310 desiredRoles !== undefined &&
311 !sameIdSet(
312 (observed.roles ?? []).map((role) => role.id),
313 desiredRoles,
314 );
315 const policiesDirty =
316 news.policies !== undefined &&
317 !samePolicies(observed.policies ?? [], news.policies);
318 if (rolesDirty || policiesDirty) {
319 observed = yield* accounts.updateMember({
320 accountId,
321 memberId,
322 roles: desiredRoles?.map((id) => ({ id })),
323 policies: news.policies,
324 });
325 }
326
327 return toAttributes(observed, accountId);
328 }),
329
330 delete: Effect.fn(function* ({ output }) {
331 // Removing a member cancels a pending invite or revokes an accepted
332 // membership. An already-gone member surfaces as `MemberNotFound`
333 // (Cloudflare error code 1003) — that's success for delete.
334 yield* accounts
335 .deleteMember({
336 accountId: output.accountId,
337 memberId: output.memberId,
338 })
339 .pipe(Effect.catchTag("MemberNotFound", () => Effect.void));
340 }),
341 });
342
343/**
344 * Look up a legacy account role by its exact name (e.g. `"Administrator"`,
345 * `"Administrator Read Only"`). Returns `undefined` when the account has no
346 * role with that name. Useful for resolving the `roles` prop of
347 * {@link Member} without hard-coding role IDs.
348 */
349export const findAccountRoleByName = (accountId: string, name: string) =>
350 accounts.listRoles.items({ accountId }).pipe(
351 Stream.runCollect,
352 Effect.map((chunk) => Array.from(chunk).find((role) => role.name === name)),
353 );
354
355// ---------------------------------------------------------------------------
356// API helpers
357// ---------------------------------------------------------------------------
358
359type ObservedMember =
360 | accounts.GetMemberResponse
361 | accounts.CreateMemberResponse
362 | accounts.UpdateMemberResponse;
363
364/**
365 * Read a membership by id, mapping "gone" (`MemberNotFound`, Cloudflare
366 * error code 1003) to `undefined`.
367 */
368const getMember = (accountId: string, memberId: string) =>
369 accounts.getMember({ accountId, memberId }).pipe(
370 Effect.map((member): ObservedMember | undefined => member),
371 Effect.catchTag("MemberNotFound", () => Effect.succeed(undefined)),
372 );
373
374/**
375 * Find a membership by email. Emails are unique within an account, so at
376 * most one membership can match. The comparison is case-insensitive —
377 * Cloudflare normalizes addresses.
378 */
379const findByEmail = (accountId: string, email: string) =>
380 accounts.listMembers.items({ accountId }).pipe(
381 Stream.runCollect,
382 Effect.flatMap((chunk) => {
383 const match = Array.from(chunk).find(
384 (member) => member.email != null && sameEmail(member.email, email),
385 );
386 // The list payload omits nothing we need, but re-read through
387 // `getMember` so every code path observes the same response shape.
388 return match?.id != null
389 ? getMember(accountId, match.id)
390 : Effect.succeed(undefined);
391 }),
392 );
393
394const sameEmail = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
395
396const sameIdSet = (observed: readonly string[], desired: readonly string[]) =>
397 observed.length === desired.length &&
398 [...observed].sort().join(",") === [...desired].sort().join(",");
399
400const samePolicies = (
401 observed: NonNullable<accounts.GetMemberResponse["policies"]>,
402 desired: MemberPolicy[],
403) => {
404 if (observed.length !== desired.length) return false;
405 const normalize = (policies: readonly NormalizedPolicy[]) =>
406 policies
407 .map(
408 (policy) =>
409 `${policy.access}:${idList(policy.permissionGroups)}:${idList(policy.resourceGroups)}`,
410 )
411 .sort()
412 .join("|");
413 return (
414 normalize(
415 observed.map((policy) => ({
416 access: policy.access ?? "allow",
417 permissionGroups: policy.permissionGroups ?? [],
418 resourceGroups: policy.resourceGroups ?? [],
419 })),
420 ) === normalize(desired)
421 );
422};
423
424interface NormalizedPolicy {
425 access: string;
426 permissionGroups: readonly { id: string }[];
427 resourceGroups: readonly { id: string }[];
428}
429
430const idList = (groups: readonly { id: string }[]) =>
431 groups
432 .map((group) => group.id)
433 .sort()
434 .join(",");
435
436const toAttributes = (
437 member: ObservedMember,
438 accountId: string,
439): MemberAttributes => ({
440 memberId: member.id ?? "",
441 accountId,
442 email: member.email ?? "",
443 status: (member.status ?? "pending") as MemberStatus,
444 roles: (member.roles ?? []).map((role) => ({
445 id: role.id,
446 name: role.name,
447 })),
448 policies: member.policies
449 ? member.policies.map((policy) => ({
450 access: (policy.access ?? "allow") as "allow" | "deny",
451 permissionGroups: (policy.permissionGroups ?? []).map((group) => ({
452 id: group.id,
453 })),
454 resourceGroups: (policy.resourceGroups ?? []).map((group) => ({
455 id: group.id,
456 })),
457 }))
458 : undefined,
459 userId: member.user?.id ?? undefined,
460});