1import * as intel from "@distilled.cloud/cloudflare/intel";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10
11const TypeId = "Cloudflare.Intel.IndicatorFeedPermission" as const;
12type TypeId = typeof TypeId;
13
14export interface IndicatorFeedPermissionProps {
15 /**
16 * The ID of the indicator feed to grant access to — e.g. `feed.feedId`.
17 * Immutable — changing it triggers a replacement.
18 */
19 feedId: number;
20 /**
21 * The Cloudflare account tag of the consumer account being granted
22 * access to the feed. Immutable — changing it triggers a replacement.
23 */
24 accountTag: string;
25}
26
27export interface IndicatorFeedPermissionAttributes {
28 /** The ID of the indicator feed the grant is on. */
29 feedId: number;
30 /** The Cloudflare account tag of the consumer account. */
31 accountTag: string;
32 /** The Cloudflare account that owns the feed (the granting account). */
33 accountId: string;
34}
35
36export type IndicatorFeedPermission = Resource<
37 TypeId,
38 IndicatorFeedPermissionProps,
39 IndicatorFeedPermissionAttributes,
40 never,
41 Providers
42>;
43
44/**
45 * A permission grant on a Cloudflare custom Indicator Feed, giving another
46 * Cloudflare account access to consume the feed.
47 *
48 * This is an existence-only resource: it has no mutable aspects beyond its
49 * identity (feed + consumer account tag), so changing either property
50 * triggers a replacement. Cloudflare's add/remove endpoints are idempotent
51 * PUTs, so reconcile and delete are simple ensure/remove calls.
52 *
53 * Cloudflare exposes no API to list the grantees of a feed from the
54 * provider side (the permissions "view" endpoint lists feeds the *calling*
55 * account can consume), so `read` reports the last known state.
56 * ### Granting Access
57 * **Example:** Grant a consumer account access to a feed
58 * ```typescript
59 * const feed = yield* Cloudflare.Intel.IndicatorFeed("threat-feed", {
60 * description: "Indicators observed by our honeypots",
61 * });
62 *
63 * yield* Cloudflare.Intel.IndicatorFeedPermission("partner-access", {
64 * feedId: feed.feedId,
65 * accountTag: "023e105f4ecef8ad9ca31a8372d0c353",
66 * });
67 * ```
68 *
69 * @see https://developers.cloudflare.com/security-center/indicator-feeds/
70 *
71 * @resource
72 * @product Intel
73 * @category Observability & Analytics
74 */
75export const IndicatorFeedPermission =
76 Resource<IndicatorFeedPermission>(TypeId);
77
78/**
79 * Returns true if the given value is an IndicatorFeedPermission resource.
80 */
81export const isIndicatorFeedPermission = (
82 value: unknown,
83): value is IndicatorFeedPermission =>
84 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
85
86export const IndicatorFeedPermissionProvider = () =>
87 Provider.succeed(IndicatorFeedPermission, {
88 stables: ["feedId", "accountTag", "accountId"],
89
90 // Non-listable: a grant is keyed by {feedId, accountTag} from the
91 // granting account's side, and Cloudflare exposes no provider-side API
92 // to enumerate a feed's grantees. The only related collection op
93 // (`listIndicatorFeedPermissions`, the `/permissions/view` endpoint)
94 // returns feeds the *calling* account can consume — feed metadata
95 // (id/name/flags) with no `accountTag` — which is the inverse
96 // relationship and cannot reconstruct the `read` Attributes shape.
97 list: () => Effect.succeed<IndicatorFeedPermissionAttributes[]>([]),
98
99 diff: Effect.fn(function* ({ olds, news }) {
100 if (!isResolved(news)) return undefined;
101 // Identity change — both props are the resource's identity, so any
102 // change is a replacement. Compare only once both sides are concrete.
103 if (typeof olds?.feedId === "number" && olds.feedId !== news.feedId) {
104 return { action: "replace" } as const;
105 }
106 if (
107 typeof olds?.accountTag === "string" &&
108 olds.accountTag !== news.accountTag
109 ) {
110 return { action: "replace" } as const;
111 }
112 return undefined;
113 }),
114
115 read: Effect.fn(function* ({ output }) {
116 // Cloudflare exposes no provider-side API to list a feed's grantees
117 // (the "view" endpoint is consumer-side), so the recorded output is
118 // the best observation available. The identity is fully
119 // user-specified and the add PUT is idempotent, so reconcile
120 // re-asserts the grant regardless.
121 return output;
122 }),
123
124 reconcile: Effect.fn(function* ({ news, output }) {
125 const { accountId } = yield* yield* CloudflareEnvironment;
126 // Inputs have been resolved to concrete values by Plan.
127 const feedId = news.feedId as number;
128 const accountTag = news.accountTag;
129
130 // Ensure — the add endpoint is an idempotent PUT: granting an
131 // already-granted account succeeds, so there is no race to tolerate
132 // and no observe step possible (no provider-side list API).
133 yield* intel.createIndicatorFeedPermission({
134 accountId: output?.accountId ?? accountId,
135 feedId,
136 accountTag,
137 });
138
139 return {
140 feedId,
141 accountTag,
142 accountId: output?.accountId ?? accountId,
143 };
144 }),
145
146 delete: Effect.fn(function* ({ output }) {
147 // Idempotent PUT — removing a grant that is already gone succeeds.
148 // A feed deleted out-of-band (or never visible) surfaces as the
149 // typed IndicatorFeedNotFound, which also means the grant is gone.
150 yield* intel
151 .deleteIndicatorFeedPermission({
152 accountId: output.accountId,
153 feedId: output.feedId,
154 accountTag: output.accountTag,
155 })
156 .pipe(Effect.catchTag("IndicatorFeedNotFound", () => Effect.void));
157 }),
158 });