1import * as resourceSharing from "@distilled.cloud/cloudflare/resource-sharing";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Stream from "effect/Stream";
6
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.ResourceSharing.ShareRecipient" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Association status of a share recipient. Association is eventually
18 * consistent — a new recipient transitions `associating → associated`.
19 */
20export type ShareRecipientAssociationStatus =
21 | "associating"
22 | "associated"
23 | "disassociating"
24 | "disassociated";
25
26export type ShareRecipientProps = {
27 /**
28 * The share the recipient is added to. Changing the share triggers a
29 * replacement.
30 */
31 shareId: string;
32 /**
33 * Recipient account identifier. Exactly one of `accountId` /
34 * `organizationId` must be set. Changing it triggers a replacement.
35 */
36 accountId?: string;
37 /**
38 * Recipient organization identifier. Exactly one of `accountId` /
39 * `organizationId` must be set. Changing it triggers a replacement.
40 */
41 organizationId?: string;
42};
43
44export type ShareRecipientAttributes = {
45 /**
46 * Share recipient identifier tag. Stable for the life of the recipient.
47 */
48 recipientId: string;
49 /**
50 * The Cloudflare account that owns (sends) the share.
51 */
52 accountId: string;
53 /**
54 * The share the recipient belongs to.
55 */
56 shareId: string;
57 /**
58 * The recipient account identifier reported by Cloudflare.
59 */
60 recipientAccountId: string;
61 /**
62 * Association status of the recipient. Eventually consistent —
63 * `associating` settles to `associated`.
64 */
65 associationStatus: ShareRecipientAssociationStatus;
66 /**
67 * When the recipient was created.
68 */
69 created: string;
70 /**
71 * When the recipient was last modified.
72 */
73 modified: string;
74};
75
76export type ShareRecipient = Resource<
77 TypeId,
78 ShareRecipientProps,
79 ShareRecipientAttributes,
80 never,
81 Providers
82>;
83
84/**
85 * A recipient on an existing Cloudflare share — grants another account or
86 * organization access to the share's resources.
87 *
88 * This is an existence-only resource: there is no update API, so every prop
89 * change triggers a replacement. Association is eventually consistent
90 * (`associating → associated`). Do not manage the same recipient both inline
91 * on `Share.recipients` and through this resource.
92 * ### Adding a Recipient
93 * **Example:** Share with another account
94 * ```typescript
95 * const recipient = yield* Cloudflare.ResourceSharing.ShareRecipient("Partner", {
96 * shareId: share.shareId,
97 * accountId: "<recipient-account-id>",
98 * });
99 * ```
100 *
101 * **Example:** Share with an organization
102 * ```typescript
103 * const recipient = yield* Cloudflare.ResourceSharing.ShareRecipient("Org", {
104 * shareId: share.shareId,
105 * organizationId: "<recipient-organization-id>",
106 * });
107 * ```
108 *
109 * @see https://developers.cloudflare.com/fundamentals/manage-account-resources/
110 *
111 * @resource
112 * @product Resource Sharing
113 * @category Account & Identity
114 */
115export const ShareRecipient = Resource<ShareRecipient>(TypeId);
116
117/**
118 * Returns true if the given value is a ShareRecipient resource.
119 */
120export const isShareRecipient = (value: unknown): value is ShareRecipient =>
121 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
122
123export const ShareRecipientProvider = () =>
124 Provider.succeed(ShareRecipient, {
125 stables: [
126 "recipientId",
127 "accountId",
128 "shareId",
129 "recipientAccountId",
130 "created",
131 ],
132 diff: Effect.fn(function* ({ olds, news, output }) {
133 if (!isResolved(news)) return undefined;
134 const { accountId } = yield* yield* CloudflareEnvironment;
135 if ((output?.accountId ?? accountId) !== accountId) {
136 return { action: "replace" } as const;
137 }
138 // No update API — every identity change is a replacement. By diff
139 // time both sides are resolved strings.
140 const oldShareId = output?.shareId ?? olds?.shareId;
141 if (
142 typeof oldShareId === "string" &&
143 typeof news.shareId === "string" &&
144 oldShareId !== news.shareId
145 ) {
146 return { action: "replace" } as const;
147 }
148 const oldTarget =
149 output?.recipientAccountId ??
150 (olds?.accountId as string | undefined) ??
151 (olds?.organizationId as string | undefined);
152 const newTarget =
153 (news.accountId as string | undefined) ??
154 (news.organizationId as string | undefined);
155 if (
156 typeof oldTarget === "string" &&
157 typeof newTarget === "string" &&
158 oldTarget !== newTarget
159 ) {
160 return { action: "replace" } as const;
161 }
162 return undefined;
163 }),
164 read: Effect.fn(function* ({ olds, output }) {
165 const { accountId } = yield* yield* CloudflareEnvironment;
166 const acct = output?.accountId ?? accountId;
167 const shareId = output?.shareId ?? (olds?.shareId as string | undefined);
168 if (shareId === undefined) return undefined;
169
170 if (output?.recipientId) {
171 const observed = yield* getRecipient(acct, shareId, output.recipientId);
172 return observed ? toAttributes(observed, acct, shareId) : undefined;
173 }
174 // Cold read — recover from lost state by matching the recipient
175 // account/organization id among the share's recipients.
176 const target =
177 (olds?.accountId as string | undefined) ??
178 (olds?.organizationId as string | undefined);
179 if (target === undefined) return undefined;
180 const match = yield* findRecipient(acct, shareId, target);
181 return match ? toAttributes(match, acct, shareId) : undefined;
182 }),
183 reconcile: Effect.fn(function* ({ news, output }) {
184 const { accountId } = yield* yield* CloudflareEnvironment;
185 const acct = output?.accountId ?? accountId;
186 const shareId = news.shareId as string;
187 const targetAccountId = news.accountId as string | undefined;
188 const targetOrganizationId = news.organizationId as string | undefined;
189
190 // Observe — the recipientId cached on `output` is a hint; fall
191 // through to a natural-key lookup so an AlreadyExists race converges
192 // instead of failing, then to create. Existence-only resource: there
193 // is no sync step.
194 const observed =
195 (output?.recipientId
196 ? yield* getRecipient(acct, shareId, output.recipientId)
197 : undefined) ??
198 (yield* findRecipient(
199 acct,
200 shareId,
201 targetAccountId ?? targetOrganizationId ?? "",
202 ));
203
204 if (observed) {
205 return toAttributes(observed, acct, shareId);
206 }
207
208 // Ensure — greenfield (or out-of-band delete).
209 const created = yield* resourceSharing.createRecipient({
210 accountId: acct,
211 shareId,
212 recipientAccountId: targetAccountId,
213 organizationId: targetOrganizationId,
214 });
215 return toAttributes(created, acct, shareId);
216 }),
217 delete: Effect.fn(function* ({ output }) {
218 // Disassociation is eventually consistent; the DELETE call is
219 // idempotent against a missing recipient.
220 yield* resourceSharing
221 .deleteRecipient({
222 accountId: output.accountId,
223 shareId: output.shareId,
224 recipientId: output.recipientId,
225 })
226 .pipe(Effect.catchTag("ShareRecipientNotFound", () => Effect.void));
227 }),
228 list: Effect.fn(function* () {
229 const { accountId } = yield* yield* CloudflareEnvironment;
230 // Parent fan-out: recipients are keyed by their parent Share, which is
231 // account-scoped. Enumerate the account's owned (sent) shares, then list
232 // recipients within each share with bounded concurrency.
233 const shareIds = yield* resourceSharing.listResourceSharings
234 .pages({ accountId, kind: "sent" })
235 .pipe(
236 Stream.runCollect,
237 Effect.map((chunk) =>
238 Array.from(chunk).flatMap((page) =>
239 (page.result ?? []).map((share) => share.id),
240 ),
241 ),
242 // The account cannot enumerate shares — nothing to list.
243 Effect.catchTag("Forbidden", () => Effect.succeed([] as string[])),
244 );
245
246 const rows = yield* Effect.forEach(
247 shareIds,
248 (shareId) =>
249 resourceSharing.listRecipients.pages({ accountId, shareId }).pipe(
250 Stream.runCollect,
251 Effect.map((chunk) =>
252 Array.from(chunk).flatMap((page) =>
253 (page.result ?? [])
254 // Disassociated recipients are "gone" — match `read`.
255 .filter((r) => r.associationStatus !== "disassociated")
256 .map((r) => toAttributes(r, accountId, shareId)),
257 ),
258 ),
259 // The share vanished mid-enumeration or isn't ours — skip it.
260 Effect.catchTag(["ShareNotFound", "Forbidden"], () =>
261 Effect.succeed([] as ShareRecipientAttributes[]),
262 ),
263 ),
264 { concurrency: 10 },
265 );
266 return rows.flat();
267 }),
268 });
269
270type ObservedRecipient = resourceSharing.GetRecipientResponse;
271
272/**
273 * Read a recipient by id, mapping "gone" (`ShareRecipientNotFound`, HTTP
274 * 404) and the terminal `disassociated` status to `undefined`.
275 */
276const getRecipient = (
277 accountId: string,
278 shareId: string,
279 recipientId: string,
280) =>
281 resourceSharing.getRecipient({ accountId, shareId, recipientId }).pipe(
282 Effect.map((recipient): ObservedRecipient | undefined =>
283 recipient.associationStatus === "disassociated" ? undefined : recipient,
284 ),
285 Effect.catchTag("ShareRecipientNotFound", () => Effect.succeed(undefined)),
286 );
287
288/**
289 * Find a live recipient by its account/organization id. A missing share
290 * surfaces as `ShareNotFound` — treat it as "no recipient".
291 */
292const findRecipient = (accountId: string, shareId: string, target: string) =>
293 resourceSharing.listRecipients
294 .items({ accountId, shareId, perPage: 50 })
295 .pipe(
296 Stream.filter(
297 (r) =>
298 r.accountId === target && r.associationStatus !== "disassociated",
299 ),
300 Stream.runHead,
301 Effect.map(Option.getOrUndefined),
302 Effect.catchTag("ShareNotFound", () => Effect.succeed(undefined)),
303 );
304
305const toAttributes = (
306 recipient:
307 | ObservedRecipient
308 | resourceSharing.CreateRecipientResponse
309 | resourceSharing.ListRecipientsResponse["result"][number],
310 accountId: string,
311 shareId: string,
312): ShareRecipientAttributes => ({
313 recipientId: recipient.id,
314 accountId,
315 shareId,
316 recipientAccountId: recipient.accountId,
317 // Distilled widens generated string enums to open unions (`string & {}`).
318 associationStatus:
319 recipient.associationStatus as ShareRecipientAssociationStatus,
320 created: recipient.created,
321 modified: recipient.modified,
322});