1import * as resourceSharing from "@distilled.cloud/cloudflare/resource-sharing";
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 { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.ResourceSharing.Share" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Type of resource that can be shared across accounts/organizations.
18 */
19export type ShareableResourceType =
20 | "custom-ruleset"
21 | "gateway-policy"
22 | "gateway-destination-ip"
23 | "gateway-block-page-settings"
24 | "gateway-extended-email-matching"
25 | "idp-federation-grant";
26
27/**
28 * Lifecycle status of a share. Deletion is asynchronous — a deleted share
29 * transitions `active → deleting → deleted`.
30 */
31export type ShareStatus = "active" | "deleting" | "deleted";
32
33/**
34 * A recipient of a share — exactly one of `accountId` / `organizationId`.
35 */
36export type ShareRecipientInput = {
37 /**
38 * Recipient account identifier. Exactly one of `accountId` /
39 * `organizationId` must be set.
40 */
41 accountId?: string;
42 /**
43 * Recipient organization identifier. Exactly one of `accountId` /
44 * `organizationId` must be set.
45 */
46 organizationId?: string;
47};
48
49/**
50 * A resource entry shared by a share.
51 */
52export type ShareResourceInput = {
53 /**
54 * Type of the shared resource (e.g. `gateway-policy`).
55 */
56 resourceType: ShareableResourceType;
57 /**
58 * Identifier of the resource being shared (e.g. the gateway policy id).
59 */
60 resourceId: string;
61 /**
62 * Account that owns the resource being shared.
63 * @default the current account
64 */
65 resourceAccountId?: string;
66 /**
67 * Resource metadata forwarded to the share API.
68 * @default {}
69 */
70 meta?: unknown;
71};
72
73export type ShareProps = {
74 /**
75 * The name of the share. The only share-level mutable field. If omitted, a
76 * unique name is generated from the app, stage, and logical ID.
77 * @default ${app}-${stage}-${id}
78 */
79 name?: string;
80 /**
81 * Recipients of the share. The create API requires at least one. Changes
82 * are reconciled via the recipient sub-API (add/remove deltas). Do not mix
83 * with standalone `ShareRecipient` resources on the same share — the Share
84 * provider treats this list as the full desired set.
85 */
86 recipients: ShareRecipientInput[];
87 /**
88 * Resources shared by the share. The create API requires at least one.
89 * Changes are reconciled via the resource sub-API (`meta` updates in place;
90 * changing `resourceType`/`resourceId` of an entry deletes and recreates
91 * that entry). Do not mix with standalone `ShareResource` resources on the
92 * same share — the Share provider treats this list as the full desired set.
93 */
94 resources: ShareResourceInput[];
95};
96
97export type ShareAttributes = {
98 /**
99 * Share identifier tag. Stable across updates.
100 */
101 shareId: string;
102 /**
103 * The Cloudflare account that owns (sends) the share.
104 */
105 accountId: string;
106 /**
107 * The name of the share.
108 */
109 name: string;
110 /**
111 * Lifecycle status of the share.
112 */
113 status: ShareStatus;
114 /**
115 * Whether the share targets an account or an organization.
116 */
117 targetType: "account" | "organization";
118 /**
119 * Whether this share is sent by or received by the current account.
120 */
121 kind: "sent" | "received";
122 /**
123 * Organization identifier of the owning account.
124 */
125 organizationId: string;
126 /**
127 * When the share was created.
128 */
129 created: string;
130 /**
131 * When the share was last modified.
132 */
133 modified: string;
134};
135
136export type Share = Resource<
137 TypeId,
138 ShareProps,
139 ShareAttributes,
140 never,
141 Providers
142>;
143
144/**
145 * A Cloudflare resource share — shares account-level configuration (gateway
146 * policies, custom rulesets, IdP federation grants, …) with another account
147 * or organization.
148 *
149 * The create API requires at least one recipient and one resource, so both
150 * are seeded inline. Post-create changes to those arrays are reconciled
151 * through the recipient/resource sub-APIs; only `name` is mutable on the
152 * share itself. Deletion is asynchronous (`active → deleting → deleted`).
153 * ### Creating a Share
154 * **Example:** Share a gateway policy with another account
155 * ```typescript
156 * const policy = yield* Cloudflare.Gateway.Rule("BlockPhishing", {
157 * action: "block",
158 * traffic: 'dns.fqdn == "phishing.example"',
159 * filters: ["dns"],
160 * });
161 *
162 * const share = yield* Cloudflare.ResourceSharing.Share("PolicyShare", {
163 * recipients: [{ accountId: "<recipient-account-id>" }],
164 * resources: [
165 * { resourceType: "gateway-policy", resourceId: policy.ruleId },
166 * ],
167 * });
168 * ```
169 *
170 * ### Updating a Share
171 * **Example:** Rename in place
172 * ```typescript
173 * const share = yield* Cloudflare.ResourceSharing.Share("PolicyShare", {
174 * name: "security-baseline-v2",
175 * recipients: [{ accountId: "<recipient-account-id>" }],
176 * resources: [
177 * { resourceType: "gateway-policy", resourceId: policy.ruleId },
178 * ],
179 * });
180 * ```
181 *
182 * @see https://developers.cloudflare.com/fundamentals/manage-account-resources/
183 *
184 * @resource
185 * @product Resource Sharing
186 * @category Account & Identity
187 */
188export const Share = Resource<Share>(TypeId);
189
190/**
191 * Returns true if the given value is a Share resource.
192 */
193export const isShare = (value: unknown): value is Share =>
194 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
195
196export const ShareProvider = () =>
197 Provider.succeed(Share, {
198 stables: ["shareId", "accountId", "organizationId", "created"],
199 // Account collection — enumerate every share this account sends
200 // (the ones we own and can delete), exhaustively paginated, mapped
201 // into the same Attributes shape `read` returns. Deleted shares are
202 // excluded since they no longer exist.
203 list: Effect.fn(function* () {
204 const { accountId } = yield* yield* CloudflareEnvironment;
205 return yield* resourceSharing.listResourceSharings
206 .pages({ accountId, kind: "sent", perPage: 50 })
207 .pipe(
208 Stream.runCollect,
209 Effect.map((chunk) =>
210 Array.from(chunk).flatMap((page) =>
211 (page.result ?? [])
212 .filter((s) => s.status !== "deleted")
213 .map((s) => toAttributes(s, accountId)),
214 ),
215 ),
216 );
217 }),
218 diff: Effect.fn(function* ({ news, output }) {
219 if (!isResolved(news)) return undefined;
220 const { accountId } = yield* yield* CloudflareEnvironment;
221 if ((output?.accountId ?? accountId) !== accountId) {
222 return { action: "replace" } as const;
223 }
224 return undefined;
225 }),
226 read: Effect.fn(function* ({ id, olds, output }) {
227 const { accountId } = yield* yield* CloudflareEnvironment;
228 const acct = output?.accountId ?? accountId;
229
230 if (output?.shareId) {
231 const observed = yield* getShare(acct, output.shareId);
232 return observed ? toAttributes(observed, acct) : undefined;
233 }
234 // Cold read — recover from lost state by matching the deterministic
235 // physical name among shares we sent. Names are not unique on
236 // Cloudflare's side; an exact match is the best identity we have.
237 const name = yield* createShareName(id, olds?.name);
238 const match = yield* findByName(acct, name);
239 return match ? toAttributes(match, acct) : undefined;
240 }),
241 reconcile: Effect.fn(function* ({ id, news, output }) {
242 const { accountId } = yield* yield* CloudflareEnvironment;
243 const name = yield* createShareName(id, news.name);
244 const desiredRecipients = news.recipients.map((r) => ({
245 accountId: r.accountId as string | undefined,
246 organizationId: r.organizationId as string | undefined,
247 }));
248 const desiredResources = news.resources.map((r) => ({
249 resourceType: r.resourceType,
250 resourceId: r.resourceId as string,
251 resourceAccountId: (r.resourceAccountId as string) ?? accountId,
252 meta: r.meta ?? {},
253 }));
254
255 // Observe — the shareId cached on `output` is a hint, not a
256 // guarantee: a missing (or fully deleted) share falls through and
257 // we recreate.
258 const observed = output?.shareId
259 ? yield* getShare(output.accountId ?? accountId, output.shareId)
260 : undefined;
261
262 if (!observed) {
263 // Ensure — greenfield (or out-of-band delete): the create API
264 // requires both arrays, so seed them inline. Share names are not
265 // unique so there is no AlreadyExists race to tolerate.
266 const created = yield* resourceSharing.createResourceSharing({
267 accountId,
268 name,
269 recipients: desiredRecipients,
270 resources: desiredResources,
271 });
272 return toAttributes(created, accountId);
273 }
274
275 const acct = output?.accountId ?? accountId;
276
277 // Sync name — the only share-level mutable field.
278 if (observed.name !== name) {
279 yield* resourceSharing.updateResourceSharing({
280 accountId: acct,
281 shareId: observed.id,
282 name,
283 });
284 }
285
286 // Sync recipients — diff observed cloud state against desired and
287 // apply add/remove deltas through the recipient sub-API.
288 const observedRecipients = yield* resourceSharing.listRecipients
289 .items({ accountId: acct, shareId: observed.id, perPage: 50 })
290 .pipe(
291 Stream.runCollect,
292 Effect.map((chunk) => Array.from(chunk)),
293 );
294 const liveRecipients = observedRecipients.filter(
295 (r) => r.associationStatus !== "disassociated",
296 );
297 for (const desired of desiredRecipients) {
298 const match = liveRecipients.find(
299 (r) => r.accountId === (desired.accountId ?? desired.organizationId),
300 );
301 if (!match) {
302 yield* resourceSharing.createRecipient({
303 accountId: acct,
304 shareId: observed.id,
305 recipientAccountId: desired.accountId,
306 organizationId: desired.organizationId,
307 });
308 }
309 }
310 for (const live of liveRecipients) {
311 const wanted = desiredRecipients.some(
312 (d) => (d.accountId ?? d.organizationId) === live.accountId,
313 );
314 if (!wanted) {
315 yield* resourceSharing
316 .deleteRecipient({
317 accountId: acct,
318 shareId: observed.id,
319 recipientId: live.id,
320 })
321 .pipe(Effect.catchTag("ShareRecipientNotFound", () => Effect.void));
322 }
323 }
324
325 // Sync resources — keyed by (resourceType, resourceId): create
326 // missing entries, update `meta` in place, delete extras.
327 const observedResources = yield* resourceSharing.listResources
328 .items({ accountId: acct, shareId: observed.id, perPage: 50 })
329 .pipe(
330 Stream.runCollect,
331 Effect.map((chunk) => Array.from(chunk)),
332 );
333 const liveResources = observedResources.filter(
334 (r) => r.status !== "deleted" && r.status !== "deleting",
335 );
336 for (const desired of desiredResources) {
337 const match = liveResources.find(
338 (r) =>
339 r.resourceType === desired.resourceType &&
340 r.resourceId === desired.resourceId,
341 );
342 if (!match) {
343 yield* resourceSharing.createResource({
344 accountId: acct,
345 shareId: observed.id,
346 resourceType: desired.resourceType,
347 resourceId: desired.resourceId,
348 resourceAccountId: desired.resourceAccountId,
349 meta: desired.meta,
350 });
351 } else if (
352 JSON.stringify(match.meta ?? {}) !==
353 JSON.stringify(desired.meta ?? {})
354 ) {
355 yield* resourceSharing.updateResource({
356 accountId: acct,
357 shareId: observed.id,
358 shareResourceId: match.id,
359 meta: desired.meta,
360 });
361 }
362 }
363 for (const live of liveResources) {
364 const wanted = desiredResources.some(
365 (d) =>
366 d.resourceType === live.resourceType &&
367 d.resourceId === live.resourceId,
368 );
369 if (!wanted) {
370 yield* resourceSharing
371 .deleteResource({
372 accountId: acct,
373 shareId: observed.id,
374 shareResourceId: live.id,
375 })
376 .pipe(Effect.catchTag("ShareResourceNotFound", () => Effect.void));
377 }
378 }
379
380 // Return — re-read for fresh attributes after the sub-API syncs.
381 const fresh = yield* getShare(acct, observed.id);
382 return fresh ? toAttributes(fresh, acct) : toAttributes(observed, acct);
383 }),
384 delete: Effect.fn(function* ({ output }) {
385 // Deletion is asynchronous (`active → deleting → deleted`); the
386 // DELETE call itself is idempotent against a missing share.
387 yield* resourceSharing
388 .deleteResourceSharing({
389 accountId: output.accountId,
390 shareId: output.shareId,
391 })
392 .pipe(Effect.catchTag("ShareNotFound", () => Effect.void));
393 }),
394 });
395
396type ObservedShare = resourceSharing.GetResourceSharingResponse;
397
398/**
399 * Read a share by id, mapping "gone" (`ShareNotFound`, Cloudflare error code
400 * 1004 / HTTP 404) and the terminal `deleted` status to `undefined`.
401 */
402const getShare = (accountId: string, shareId: string) =>
403 resourceSharing.getResourceSharing({ accountId, shareId }).pipe(
404 Effect.map((share): ObservedShare | undefined =>
405 share.status === "deleted" ? undefined : share,
406 ),
407 Effect.catchTag("ShareNotFound", () => Effect.succeed(undefined)),
408 );
409
410/**
411 * Find a sent share by exact name. If several shares carry the same name,
412 * pick the oldest for determinism.
413 */
414const findByName = (accountId: string, name: string) =>
415 resourceSharing
416 .listResourceSharings({ accountId, kind: "sent", perPage: 50 })
417 .pipe(
418 Effect.map((list) =>
419 list.result
420 .filter((s) => s.name === name && s.status !== "deleted")
421 .sort((a, b) => a.created.localeCompare(b.created))
422 .at(0),
423 ),
424 );
425
426const createShareName = (id: string, name: string | undefined) =>
427 Effect.gen(function* () {
428 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
429 });
430
431const toAttributes = (
432 share:
433 | ObservedShare
434 | resourceSharing.CreateResourceSharingResponse
435 | resourceSharing.ListResourceSharingsResponse["result"][number],
436 accountId: string,
437): ShareAttributes => ({
438 shareId: share.id,
439 accountId,
440 name: share.name,
441 // Distilled widens generated string enums to open unions (`string & {}`).
442 status: share.status as ShareStatus,
443 targetType: share.targetType as "account" | "organization",
444 kind: (share.kind ?? "sent") as "sent" | "received",
445 organizationId: share.organizationId,
446 created: share.created,
447 modified: share.modified,
448});