1import * as securityTxt from "@distilled.cloud/cloudflare/security-txt";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { Unowned } from "../../AdoptPolicy.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10import { listAllZones } from "../Zone/lookup.ts";
11
12const TypeId = "Cloudflare.SecurityTxt.SecurityTxt" as const;
13type TypeId = typeof TypeId;
14
15export type Props = {
16 /**
17 * Zone the security.txt file belongs to. Stable — changing the zone
18 * triggers a replacement (the old zone's security.txt is deleted and a
19 * new one is created on the new zone).
20 */
21 zoneId: string;
22 /**
23 * Whether the security.txt file is served at
24 * `/.well-known/security.txt` on the zone. Mutable.
25 *
26 * @default true
27 */
28 enabled?: boolean;
29 /**
30 * Contact channels for security researchers — `mailto:` addresses,
31 * `tel:` numbers, or HTTPS URLs (RFC 9116 `Contact` field). Required —
32 * Cloudflare rejects a security.txt without it. Mutable.
33 */
34 contact: string[];
35 /**
36 * RFC 3339 timestamp after which the file's content should be
37 * considered stale (RFC 9116 `Expires` field), e.g.
38 * `2027-01-01T00:00:00Z`. Required — Cloudflare rejects a security.txt
39 * without it. Mutable.
40 */
41 expires: string;
42 /**
43 * URLs of pages crediting security researchers (RFC 9116
44 * `Acknowledgments` field). Mutable.
45 */
46 acknowledgments?: string[];
47 /**
48 * Canonical URLs where this security.txt file is served (RFC 9116
49 * `Canonical` field). Mutable.
50 */
51 canonical?: string[];
52 /**
53 * URLs of encryption keys for secure communication (RFC 9116
54 * `Encryption` field). Mutable.
55 */
56 encryption?: string[];
57 /**
58 * URLs of security-related job openings (RFC 9116 `Hiring` field).
59 * Mutable.
60 */
61 hiring?: string[];
62 /**
63 * URLs of the vulnerability disclosure policy (RFC 9116 `Policy`
64 * field). Mutable.
65 */
66 policy?: string[];
67 /**
68 * Comma-separated list of language tags the security team prefers
69 * (RFC 9116 `Preferred-Languages` field), e.g. `"en, es"`. Mutable.
70 */
71 preferredLanguages?: string;
72};
73
74export type Attributes = {
75 /** Zone the security.txt file belongs to. */
76 zoneId: string;
77 /** Whether the file is served at `/.well-known/security.txt`. */
78 enabled: boolean;
79 /** Contact channels for security researchers. */
80 contact: string[];
81 /** RFC 3339 timestamp after which the file is considered stale. */
82 expires: string;
83 /** URLs of pages crediting security researchers. */
84 acknowledgments: string[] | undefined;
85 /** Canonical URLs where this security.txt file is served. */
86 canonical: string[] | undefined;
87 /** URLs of encryption keys for secure communication. */
88 encryption: string[] | undefined;
89 /** URLs of security-related job openings. */
90 hiring: string[] | undefined;
91 /** URLs of the vulnerability disclosure policy. */
92 policy: string[] | undefined;
93 /** Comma-separated list of preferred language tags. */
94 preferredLanguages: string | undefined;
95};
96
97export type SecurityTxt = Resource<TypeId, Props, Attributes, never, Providers>;
98
99/**
100 * A zone's `security.txt` file
101 * (`/zones/{zone_id}/security-center/securitytxt`), served by the
102 * Cloudflare edge at `https://<zone>/.well-known/security.txt` per
103 * RFC 9116 so security researchers know how to report vulnerabilities.
104 *
105 * The file is a singleton per zone with true create/delete semantics:
106 * creating the resource publishes the file, updating it is a full
107 * replace of every field, and destroying it removes the file from the
108 * zone entirely.
109 *
110 * Cloudflare requires the RFC 9116 mandatory fields — `contact` and
111 * `expires` — on every write.
112 * ### Publishing a security.txt
113 * **Example:** Minimal security.txt
114 * ```typescript
115 * const zone = yield* Cloudflare.Zone.Zone("Site", { name: "example.com" });
116 *
117 * yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
118 * zoneId: zone.zoneId,
119 * contact: ["mailto:security@example.com"],
120 * expires: "2027-01-01T00:00:00Z",
121 * });
122 * ```
123 *
124 * **Example:** Full security.txt with policy and acknowledgments
125 * ```typescript
126 * yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
127 * zoneId: zone.zoneId,
128 * contact: ["mailto:security@example.com", "https://example.com/report"],
129 * expires: "2027-01-01T00:00:00Z",
130 * policy: ["https://example.com/security-policy"],
131 * acknowledgments: ["https://example.com/hall-of-fame"],
132 * encryption: ["https://example.com/pgp-key.txt"],
133 * preferredLanguages: "en, es",
134 * });
135 * ```
136 *
137 * ### Pausing without deleting
138 * **Example:** Keep the configuration but stop serving the file
139 * ```typescript
140 * yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
141 * zoneId: zone.zoneId,
142 * enabled: false,
143 * contact: ["mailto:security@example.com"],
144 * expires: "2027-01-01T00:00:00Z",
145 * });
146 * ```
147 *
148 * @see https://developers.cloudflare.com/security-center/infrastructure/security-file/
149 *
150 * @resource
151 * @product Security.txt
152 * @category Application Security
153 */
154export const SecurityTxt = Resource<SecurityTxt>(TypeId, {
155 aliases: ["Cloudflare.SecurityTxt"],
156});
157
158/**
159 * Returns true if the given value is a SecurityTxt resource.
160 */
161export const isSecurityTxt = (value: unknown): value is SecurityTxt =>
162 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
163
164export const SecurityTxtProvider = () =>
165 Provider.succeed(SecurityTxt, {
166 stables: ["zoneId"],
167
168 list: Effect.fn(function* () {
169 const { accountId } = yield* yield* CloudflareEnvironment;
170 // There is no account-wide API for this per-zone singleton, and the
171 // file is only present on zones that have explicitly configured it —
172 // enumerate every zone, read its security.txt, and emit one entry per
173 // configured zone (an empty-string sentinel means unconfigured, which
174 // `read` treats as absent, so skip it).
175 const allZones = yield* listAllZones(accountId);
176 const rows = yield* Effect.forEach(
177 allZones.map((zone) => zone.id),
178 (zoneId) =>
179 securityTxt
180 .getSecurityTxt({ zoneId })
181 .pipe(
182 Effect.map((observed) =>
183 typeof observed === "string"
184 ? undefined
185 : toAttributes(zoneId, observed),
186 ),
187 ),
188 { concurrency: 10 },
189 );
190 return rows.filter((row): row is Attributes => row !== undefined);
191 }),
192
193 diff: Effect.fn(function* ({ olds = {}, news, output }) {
194 const o = olds as Props;
195 const n = news as Props;
196 // zoneId is Input<string>; compare only once both sides are concrete.
197 const oldZoneId =
198 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
199 if (
200 oldZoneId !== undefined &&
201 typeof n.zoneId === "string" &&
202 oldZoneId !== n.zoneId
203 ) {
204 return { action: "replace" } as const;
205 }
206 return undefined;
207 }),
208
209 read: Effect.fn(function* ({ output, olds }) {
210 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
211 if (!zoneId) return undefined;
212 const observed = yield* securityTxt.getSecurityTxt({ zoneId });
213 // An unconfigured zone returns an empty-string sentinel — absent.
214 if (typeof observed === "string") return undefined;
215 const attrs = toAttributes(zoneId, observed);
216 // The file is a zone singleton with no ownership markers. On a cold
217 // read (no prior output) an existing file was configured outside
218 // Alchemy — brand it `Unowned` so the engine refuses to take over
219 // unless `--adopt` is set.
220 if (output === undefined) return Unowned(attrs);
221 return attrs;
222 }),
223
224 reconcile: Effect.fn(function* ({ news }) {
225 // Inputs have been resolved to concrete strings by Plan.
226 const zoneId = news.zoneId as string;
227 const desired = desiredAttributes(zoneId, news);
228
229 // 1. Observe — read the live file (an empty-string sentinel means
230 // the zone has no security.txt yet).
231 const observed = yield* securityTxt.getSecurityTxt({ zoneId });
232
233 // 2. Sync — PUT is a full replace (a true upsert), so create and
234 // update are the same call. Skip the API entirely when the
235 // observed file already matches the desired one.
236 if (
237 typeof observed !== "string" &&
238 attributesEqual(toAttributes(zoneId, observed), desired)
239 ) {
240 return toAttributes(zoneId, observed);
241 }
242 yield* securityTxt.putSecurityTxt({
243 zoneId,
244 enabled: desired.enabled,
245 contact: desired.contact,
246 expires: desired.expires,
247 acknowledgments: desired.acknowledgments,
248 canonical: desired.canonical,
249 encryption: desired.encryption,
250 hiring: desired.hiring,
251 policy: desired.policy,
252 preferredLanguages: desired.preferredLanguages,
253 });
254
255 // 3. Return — re-read so attributes reflect what Cloudflare stored.
256 const final = yield* securityTxt.getSecurityTxt({ zoneId });
257 return typeof final === "string" ? desired : toAttributes(zoneId, final);
258 }),
259
260 delete: Effect.fn(function* ({ output }) {
261 // DELETE on an already-absent security.txt returns success, so the
262 // operation is naturally idempotent.
263 yield* securityTxt.deleteSecurityTxt({ zoneId: output.zoneId });
264 }),
265 });
266
267/** Normalize the desired state from input props into the Attributes shape. */
268const desiredAttributes = (zoneId: string, news: Props): Attributes => ({
269 zoneId,
270 enabled: news.enabled ?? true,
271 contact: [...news.contact],
272 expires: news.expires,
273 acknowledgments: nonEmpty(news.acknowledgments),
274 canonical: nonEmpty(news.canonical),
275 encryption: nonEmpty(news.encryption),
276 hiring: nonEmpty(news.hiring),
277 policy: nonEmpty(news.policy),
278 preferredLanguages:
279 news.preferredLanguages === "" ? undefined : news.preferredLanguages,
280});
281
282/** Normalize the observed GET response into the Attributes shape. */
283const toAttributes = (
284 zoneId: string,
285 observed: Exclude<securityTxt.GetSecurityTxtResponse, string>,
286): Attributes => ({
287 zoneId,
288 enabled: observed.enabled ?? false,
289 contact: [...(observed.contact ?? [])],
290 expires: observed.expires ?? "",
291 acknowledgments: nonEmpty(observed.acknowledgments),
292 canonical: nonEmpty(observed.canonical),
293 encryption: nonEmpty(observed.encryption),
294 hiring: nonEmpty(observed.hiring),
295 // Cloudflare echoes an empty string for an unset Preferred-Languages.
296 policy: nonEmpty(observed.policy),
297 preferredLanguages:
298 observed.preferredLanguages === ""
299 ? undefined
300 : (observed.preferredLanguages ?? undefined),
301});
302
303/** Null/empty list fields normalize to `undefined` (not configured). */
304const nonEmpty = (
305 values: readonly string[] | null | undefined,
306): string[] | undefined =>
307 values === null || values === undefined || values.length === 0
308 ? undefined
309 : [...values];
310
311const listEquals = (
312 a: string[] | undefined,
313 b: string[] | undefined,
314): boolean => {
315 if (a === undefined || b === undefined) return a === b;
316 return a.length === b.length && a.every((v, i) => v === b[i]);
317};
318
319const attributesEqual = (a: Attributes, b: Attributes): boolean =>
320 a.zoneId === b.zoneId &&
321 a.enabled === b.enabled &&
322 a.expires === b.expires &&
323 a.preferredLanguages === b.preferredLanguages &&
324 listEquals(a.contact, b.contact) &&
325 listEquals(a.acknowledgments, b.acknowledgments) &&
326 listEquals(a.canonical, b.canonical) &&
327 listEquals(a.encryption, b.encryption) &&
328 listEquals(a.hiring, b.hiring) &&
329 listEquals(a.policy, b.policy);