1import * as intel from "@distilled.cloud/cloudflare/intel";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5import crypto from "node:crypto";
6
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.Intel.IndicatorFeed" as const;
15type TypeId = typeof TypeId;
16
17export interface IndicatorFeedProps {
18 /**
19 * The name of the indicator feed. Not unique on Cloudflare's side, but
20 * used as the cold-state recovery identity, so keep it unique within the
21 * account. If omitted, a unique name is generated from the app, stage,
22 * and logical ID.
23 * @default ${app}-${stage}-${id}
24 */
25 name?: string;
26 /**
27 * Human-readable description of the feed.
28 */
29 description?: string;
30 /**
31 * Whether the indicator feed can be attributed to a provider (consumers
32 * see who published it).
33 * @default false
34 */
35 isAttributable?: boolean;
36 /**
37 * Whether the indicator feed data can be downloaded by consumers.
38 * @default false
39 */
40 isDownloadable?: boolean;
41 /**
42 * Whether the indicator feed is exposed to customers (publicly listed).
43 * @default false
44 */
45 isPublic?: boolean;
46 /**
47 * Inline STIX 2.x content for the feed's snapshot. When provided, the
48 * content is uploaded via the snapshot endpoint whenever it changes
49 * (tracked by content hash). Cloudflare processes uploads asynchronously
50 * — see the `latestUploadStatus` attribute.
51 */
52 snapshot?: string;
53}
54
55export interface IndicatorFeedAttributes {
56 /** The server-assigned numeric identifier for the indicator feed. */
57 feedId: number;
58 /** The Cloudflare account the feed belongs to. */
59 accountId: string;
60 /** The name of the indicator feed. */
61 name: string;
62 /** The description of the indicator feed. */
63 description: string | undefined;
64 /** Whether the feed can be attributed to a provider. */
65 isAttributable: boolean;
66 /** Whether the feed data can be downloaded by consumers. */
67 isDownloadable: boolean;
68 /** Whether the feed is exposed to customers. */
69 isPublic: boolean;
70 /** When the feed was created. */
71 createdOn: string | undefined;
72 /** When the feed was last modified. */
73 modifiedOn: string | undefined;
74 /**
75 * Status of the latest snapshot upload (`Mirroring`, `Unifying`,
76 * `Loading`, `Provisioning`, `Complete`, or `Error`), if any.
77 */
78 latestUploadStatus: string | undefined;
79 /**
80 * SHA-256 hash of the last snapshot content uploaded by this provider.
81 * Used to skip re-uploading unchanged content.
82 */
83 snapshotHash: string | undefined;
84}
85
86export type IndicatorFeed = Resource<
87 TypeId,
88 IndicatorFeedProps,
89 IndicatorFeedAttributes,
90 never,
91 Providers
92>;
93
94/**
95 * A Cloudflare custom Indicator Feed (Cloudforce One threat intelligence).
96 *
97 * Indicator feeds let approved accounts publish their own threat-intel
98 * indicators (domains, IPs, URLs) that consumer accounts can subscribe to
99 * via Gateway or download directly. Creating feeds requires the account to
100 * be approved as a feed provider (a Cloudforce One entitlement) — without
101 * it, creation fails with the typed `IndicatorFeedsNotEntitled` error.
102 *
103 * :::warning
104 * Cloudflare's API exposes **no delete endpoint** for indicator feeds.
105 * Destroying this resource orphans the feed on Cloudflare's side (a warning
106 * is logged). To avoid leaking feeds across deployments, the provider
107 * adopts an existing feed with the same name instead of creating a
108 * duplicate.
109 * :::
110 * ### Creating a Feed
111 * **Example:** Basic feed
112 * ```typescript
113 * const feed = yield* Cloudflare.Intel.IndicatorFeed("threat-feed", {
114 * description: "Indicators observed by our honeypots",
115 * });
116 * ```
117 *
118 * **Example:** Public, downloadable feed
119 * ```typescript
120 * const feed = yield* Cloudflare.Intel.IndicatorFeed("public-feed", {
121 * name: "acme-public-indicators",
122 * description: "Acme Corp public threat indicators",
123 * isPublic: true,
124 * isDownloadable: true,
125 * isAttributable: true,
126 * });
127 * ```
128 *
129 * ### Publishing Indicators
130 * **Example:** Upload a STIX 2.x snapshot inline
131 * ```typescript
132 * const feed = yield* Cloudflare.Intel.IndicatorFeed("threat-feed", {
133 * description: "Indicators observed by our honeypots",
134 * snapshot: JSON.stringify({
135 * type: "bundle",
136 * id: "bundle--0a242344-3c0b-4fdb-9f59-3e8c4a4f6b3a",
137 * objects: [],
138 * }),
139 * });
140 * ```
141 *
142 * ### Sharing a Feed
143 * **Example:** Grant another account access
144 * ```typescript
145 * yield* Cloudflare.Intel.IndicatorFeedPermission("partner-access", {
146 * feedId: feed.feedId,
147 * accountTag: "023e105f4ecef8ad9ca31a8372d0c353",
148 * });
149 * ```
150 *
151 * @see https://developers.cloudflare.com/security-center/indicator-feeds/
152 *
153 * @resource
154 * @product Intel
155 * @category Observability & Analytics
156 */
157export const IndicatorFeed = Resource<IndicatorFeed>(TypeId);
158
159/**
160 * Returns true if the given value is an IndicatorFeed resource.
161 */
162export const isIndicatorFeed = (value: unknown): value is IndicatorFeed =>
163 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
164
165export const IndicatorFeedProvider = () =>
166 Provider.succeed(IndicatorFeed, {
167 stables: ["feedId", "accountId", "createdOn"],
168
169 // Account collection — enumerate every indicator feed owned by the
170 // ambient account. The list endpoint returns a thin row (no
171 // `latestUploadStatus`), so hydrate each id via `getFeed` to produce the
172 // exact `read` Attributes shape. Accounts without the Cloudforce One feed
173 // entitlement are rejected with the typed `Forbidden` tag — treat that as
174 // an empty collection rather than failing the enumeration.
175 list: Effect.fn(function* () {
176 const { accountId } = yield* yield* CloudflareEnvironment;
177 const ids = yield* intel.listIndicatorFeeds.pages({ accountId }).pipe(
178 Stream.runCollect,
179 Effect.map((chunk) =>
180 Array.from(chunk).flatMap((page) =>
181 (page.result ?? [])
182 .map((f) => f.id)
183 .filter((id): id is number => id != null),
184 ),
185 ),
186 Effect.catchTag("Forbidden", () => Effect.succeed([] as number[])),
187 );
188 const rows = yield* Effect.forEach(
189 ids,
190 (feedId) =>
191 getFeed(accountId, feedId).pipe(
192 Effect.map((observed) =>
193 observed
194 ? toAttributes(observed, accountId, undefined)
195 : undefined,
196 ),
197 // A feed may vanish between list and get, or the account may
198 // lack access to an individual feed — skip either case.
199 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
200 ),
201 { concurrency: 10 },
202 );
203 return rows.filter(
204 (row): row is IndicatorFeedAttributes => row !== undefined,
205 );
206 }),
207
208 diff: Effect.fn(function* ({ news, output }) {
209 const { accountId } = yield* yield* CloudflareEnvironment;
210 if (!isResolved(news)) return undefined;
211 // Feeds cannot move between accounts (and cannot be deleted, so a
212 // replacement would orphan the old feed) — but a different target
213 // account still means a different physical resource.
214 if ((output?.accountId ?? accountId) !== accountId) {
215 return { action: "replace" } as const;
216 }
217 return undefined;
218 }),
219
220 read: Effect.fn(function* ({ id, output, olds }) {
221 const { accountId } = yield* yield* CloudflareEnvironment;
222 const acct = output?.accountId ?? accountId;
223
224 if (output?.feedId !== undefined) {
225 const observed = yield* getFeed(acct, output.feedId);
226 return observed
227 ? toAttributes(observed, acct, output.snapshotHash)
228 : undefined;
229 }
230 // Cold read — recover from lost state by matching the deterministic
231 // physical name. Names are not unique on Cloudflare's side; an exact
232 // match on our generated/explicit name is the best identity we have.
233 const name = yield* createFeedName(id, olds?.name);
234 const match = yield* findByName(acct, name);
235 if (match?.id != null) {
236 const observed = yield* getFeed(acct, match.id);
237 return observed ? toAttributes(observed, acct, undefined) : undefined;
238 }
239 return undefined;
240 }),
241
242 reconcile: Effect.fn(function* ({ id, news, output }) {
243 const { accountId } = yield* yield* CloudflareEnvironment;
244 const name = yield* createFeedName(id, news.name);
245
246 // Observe — the feedId cached on `output` is a hint, not a
247 // guarantee. Because feeds can never be deleted, a missing cache
248 // falls back to a lookup by name so reruns adopt the existing feed
249 // instead of leaking a duplicate.
250 let observed =
251 output?.feedId !== undefined
252 ? yield* getFeed(output.accountId ?? accountId, output.feedId)
253 : undefined;
254 if (!observed) {
255 const match = yield* findByName(accountId, name);
256 if (match?.id != null) {
257 observed = yield* getFeed(accountId, match.id);
258 }
259 }
260
261 // Ensure — greenfield: create the feed with name + description; the
262 // visibility flags are only settable via update, so fall through to
263 // the sync step with the freshly observed state.
264 if (!observed) {
265 const created = yield* intel.createIndicatorFeed({
266 accountId,
267 name,
268 description: news.description,
269 });
270 if (created.id == null) {
271 // The create response is fully optional in the API schema; fall
272 // back to the by-name lookup for the id we just created.
273 const match = yield* findByName(accountId, name);
274 observed =
275 match?.id != null ? yield* getFeed(accountId, match.id) : undefined;
276 } else {
277 observed = yield* getFeed(accountId, created.id);
278 }
279 }
280 if (!observed || observed.id == null) {
281 return yield* Effect.fail(
282 new intel.IndicatorFeedNotFound({
283 code: 0,
284 message: `Indicator feed "${name}" was not observable after creation`,
285 }),
286 );
287 }
288 const feedId = observed.id;
289
290 // Sync — diff observed cloud state against desired settings and
291 // apply only when dirty.
292 const desired = {
293 name,
294 description: news.description,
295 isAttributable: news.isAttributable ?? false,
296 isDownloadable: news.isDownloadable ?? false,
297 isPublic: news.isPublic ?? false,
298 };
299 const dirty =
300 (observed.name ?? undefined) !== desired.name ||
301 (news.description !== undefined &&
302 (observed.description ?? undefined) !== desired.description) ||
303 (observed.isAttributable ?? false) !== desired.isAttributable ||
304 (observed.isDownloadable ?? false) !== desired.isDownloadable ||
305 (observed.isPublic ?? false) !== desired.isPublic;
306 if (dirty) {
307 yield* intel.updateIndicatorFeed({
308 accountId,
309 feedId,
310 ...desired,
311 });
312 }
313
314 // Sync snapshot — upload inline STIX content whenever its hash
315 // differs from what we last uploaded. Uploads are processed
316 // asynchronously by Cloudflare (see `latestUploadStatus`).
317 let snapshotHash = output?.snapshotHash;
318 if (news.snapshot !== undefined) {
319 const hash = yield* sha256(news.snapshot);
320 if (hash !== output?.snapshotHash) {
321 yield* intel.putIndicatorFeedSnapshot({
322 accountId,
323 feedId,
324 source: news.snapshot,
325 });
326 }
327 snapshotHash = hash;
328 }
329
330 const final = yield* getFeed(accountId, feedId);
331 if (!final) {
332 return yield* Effect.fail(
333 new intel.IndicatorFeedNotFound({
334 code: 0,
335 message: `Indicator feed ${feedId} disappeared during reconcile`,
336 }),
337 );
338 }
339 return toAttributes(final, accountId, snapshotHash);
340 }),
341
342 delete: Effect.fn(function* ({ output }) {
343 // Cloudflare's API has no delete endpoint for indicator feeds — the
344 // feed is orphaned on the account. Reconcile adopts same-named feeds,
345 // so re-creating this resource later reuses the orphan.
346 yield* Effect.logWarning(
347 `Cloudflare indicator feeds cannot be deleted via the API — ` +
348 `feed ${output.feedId} ("${output.name}") on account ` +
349 `${output.accountId} is left in place.`,
350 );
351 }),
352 });
353
354type ObservedFeed = intel.GetIndicatorFeedResponse;
355
356/**
357 * Read a feed by id, mapping "gone" — Cloudflare answers HTTP 403 with
358 * "Feed N does not exist or this account does not have access", surfaced
359 * as the typed `IndicatorFeedNotFound` — to `undefined`.
360 */
361const getFeed = (accountId: string, feedId: number) =>
362 intel.getIndicatorFeed({ accountId, feedId }).pipe(
363 Effect.map((f): ObservedFeed | undefined => f),
364 Effect.catchTag("IndicatorFeedNotFound", () => Effect.succeed(undefined)),
365 );
366
367/**
368 * Find a feed by exact name. The list endpoint has no filter, so match
369 * client-side; if several feeds carry the same name, pick the oldest for
370 * determinism.
371 */
372const findByName = (accountId: string, name: string) =>
373 intel.listIndicatorFeeds.items({ accountId }).pipe(
374 Stream.filter((f) => f.name === name),
375 Stream.runCollect,
376 Effect.map((chunk) =>
377 Array.from(chunk)
378 .sort((a, b) => (a.createdOn ?? "").localeCompare(b.createdOn ?? ""))
379 .at(0),
380 ),
381 );
382
383const createFeedName = (id: string, name: string | undefined) =>
384 Effect.gen(function* () {
385 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
386 });
387
388const sha256 = (content: string) =>
389 Effect.sync(() => crypto.createHash("sha256").update(content).digest("hex"));
390
391const toAttributes = (
392 feed: ObservedFeed,
393 accountId: string,
394 snapshotHash: string | undefined,
395): IndicatorFeedAttributes => ({
396 feedId: feed.id ?? 0,
397 accountId,
398 name: feed.name ?? "",
399 description: feed.description ?? undefined,
400 isAttributable: feed.isAttributable ?? false,
401 isDownloadable: feed.isDownloadable ?? false,
402 isPublic: feed.isPublic ?? false,
403 createdOn: feed.createdOn ?? undefined,
404 modifiedOn: feed.modifiedOn ?? undefined,
405 latestUploadStatus: feed.latestUploadStatus ?? undefined,
406 snapshotHash,
407});