1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
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 { 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
14export type CustomPageType = "identity_denied" | "forbidden";
15
16export type CustomPageProps = {
17 /**
18 * Display name for the custom page. Used as a stable identifier so the
19 * provider can locate the page during adoption / state recovery. If
20 * omitted, a unique name is generated from the stack/stage/logical id.
21 *
22 * @default ${app}-${stage}-${id}
23 */
24 name?: string;
25 /**
26 * The type of Access event the page is shown for. `identity_denied` is
27 * shown when a user's identity is rejected by policy; `forbidden` is shown
28 * when access is blocked outright. Changing the type replaces the page.
29 */
30 type: CustomPageType;
31 /**
32 * The custom HTML served for the page.
33 */
34 customHtml: string;
35};
36
37export type CustomPage = Resource<
38 "Cloudflare.Access.CustomPage",
39 CustomPageProps,
40 {
41 /** UUID of the custom page assigned by Cloudflare. */
42 customPageId: string;
43 /** Cloudflare account that owns the custom page. */
44 accountId: string;
45 /** Display name reported by Cloudflare. */
46 name: string;
47 /** The Access event type the page is shown for. */
48 type: CustomPageType;
49 },
50 never,
51 Providers
52>;
53
54/**
55 * A Cloudflare Zero Trust Access custom page. Replaces the default Access
56 * block pages (`identity_denied` / `forbidden`) with custom HTML, which can
57 * then be selected on an Access application.
58 * ### Creating a Custom Page
59 * **Example:** Custom forbidden page
60 * ```typescript
61 * const page = yield* Cloudflare.Access.CustomPage("Forbidden", {
62 * type: "forbidden",
63 * customHtml: "<html><body><h1>Access denied</h1></body></html>",
64 * });
65 * ```
66 *
67 * **Example:** Custom identity-denied page with an explicit name
68 * ```typescript
69 * const page = yield* Cloudflare.Access.CustomPage("Denied", {
70 * name: "corp-identity-denied",
71 * type: "identity_denied",
72 * customHtml: "<html><body><h1>Who are you?</h1></body></html>",
73 * });
74 * ```
75 *
76 * ### Updating the HTML
77 * **Example:** HTML and name converge in place
78 * ```typescript
79 * const page = yield* Cloudflare.Access.CustomPage("Forbidden", {
80 * type: "forbidden",
81 * customHtml: "<html><body><h1>Still denied</h1></body></html>",
82 * });
83 * ```
84 *
85 * @resource
86 * @product Access
87 * @category Cloudflare One (Zero Trust)
88 */
89export const CustomPage = Resource<CustomPage>("Cloudflare.Access.CustomPage");
90
91export const isCustomPage = (value: unknown): value is CustomPage =>
92 Predicate.hasProperty(value, "Type") &&
93 value.Type === "Cloudflare.Access.CustomPage";
94
95export const CustomPageProvider = () =>
96 Provider.succeed(CustomPage, {
97 stables: ["customPageId", "accountId", "type"],
98 // Account-scoped collection (pattern b). The list response items already
99 // carry every Attribute field (uid/name/type) — read's Attributes don't
100 // include customHtml — so no per-item get is needed to match the read shape.
101 list: Effect.fn(function* () {
102 const { accountId } = yield* yield* CloudflareEnvironment;
103 return yield* zeroTrust.listAccessCustomPages.pages({ accountId }).pipe(
104 Stream.runCollect,
105 Effect.map((chunk) =>
106 Array.from(chunk).flatMap((page) =>
107 (page.result ?? [])
108 .filter((p): p is typeof p & { uid: string } => p.uid != null)
109 .map((p) => toAttrs(p, accountId)),
110 ),
111 ),
112 );
113 }),
114 diff: Effect.fn(function* ({ news, output }) {
115 const { accountId } = yield* yield* CloudflareEnvironment;
116 if (!isResolved(news)) return undefined;
117 if ((output?.accountId ?? accountId) !== accountId) {
118 return { action: "replace" } as const;
119 }
120 // The page type cannot change in place.
121 if (output && news.type !== output.type) {
122 return { action: "replace" } as const;
123 }
124 // name/customHtml converge via PUT.
125 }),
126 read: Effect.fn(function* ({ id, output, olds }) {
127 const { accountId } = yield* yield* CloudflareEnvironment;
128 const acct = output?.accountId ?? accountId;
129 if (output?.customPageId) {
130 const direct = yield* zeroTrust
131 .getAccessCustomPage({
132 accountId: acct,
133 customPageId: output.customPageId,
134 })
135 .pipe(
136 Effect.catchTag("AccessCustomPageNotFound", () =>
137 Effect.succeed(undefined),
138 ),
139 );
140 if (direct && direct.uid) {
141 return toAttrs(direct, acct);
142 }
143 }
144 const name = yield* createPageName(id, olds?.name ?? output?.name);
145 const existing = yield* findPageByName(acct, name);
146 if (!existing || !existing.uid) return undefined;
147 const full = yield* zeroTrust
148 .getAccessCustomPage({ accountId: acct, customPageId: existing.uid })
149 .pipe(
150 Effect.catchTag("AccessCustomPageNotFound", () =>
151 Effect.succeed(undefined),
152 ),
153 );
154 if (!full || !full.uid) return undefined;
155 return toAttrs(full, acct);
156 }),
157 reconcile: Effect.fn(function* ({ id, news, output }) {
158 const { accountId } = yield* yield* CloudflareEnvironment;
159 const acct = output?.accountId ?? accountId;
160 const name = yield* createPageName(id, news.name);
161
162 // Observe — prefer the cached id, fall back to a name lookup so we
163 // recover from out-of-band deletes and state-persistence failures.
164 let observed: ObservedPage | undefined;
165 if (output?.customPageId) {
166 observed = yield* zeroTrust
167 .getAccessCustomPage({
168 accountId: acct,
169 customPageId: output.customPageId,
170 })
171 .pipe(
172 Effect.catchTag("AccessCustomPageNotFound", () =>
173 Effect.succeed(undefined),
174 ),
175 );
176 }
177 if (!observed || !observed.uid) {
178 const byName = yield* findPageByName(acct, name);
179 if (byName?.uid) {
180 observed = yield* zeroTrust
181 .getAccessCustomPage({ accountId: acct, customPageId: byName.uid })
182 .pipe(
183 Effect.catchTag("AccessCustomPageNotFound", () =>
184 Effect.succeed(undefined),
185 ),
186 );
187 }
188 }
189
190 // Ensure — create the page when missing. Tolerate a same-named create
191 // race by re-observing.
192 if (!observed || !observed.uid) {
193 const created = yield* zeroTrust
194 .createAccessCustomPage({
195 accountId: acct,
196 name,
197 type: news.type,
198 customHtml: news.customHtml,
199 })
200 .pipe(
201 Effect.catch((err) =>
202 Effect.gen(function* () {
203 const existing = yield* findPageByName(acct, name);
204 if (existing && existing.uid) return existing;
205 return yield* Effect.fail(err);
206 }),
207 ),
208 );
209 if (!created.uid) {
210 return yield* Effect.fail(
211 new Error("CustomPage: created page missing uid"),
212 );
213 }
214 return toAttrs({ ...created, type: news.type }, acct);
215 }
216
217 // Sync — converge name/html via PUT only on a real delta. The GET
218 // returns the full customHtml so the comparison is observed-vs-desired.
219 if (
220 observed.name !== name ||
221 observed.type !== news.type ||
222 observed.customHtml !== news.customHtml
223 ) {
224 const updated = yield* zeroTrust.updateAccessCustomPage({
225 accountId: acct,
226 customPageId: observed.uid,
227 name,
228 type: news.type,
229 customHtml: news.customHtml,
230 });
231 observed = {
232 uid: updated.uid ?? observed.uid,
233 name: updated.name ?? name,
234 type: updated.type ?? news.type,
235 };
236 }
237
238 return toAttrs(observed, acct);
239 }),
240 delete: Effect.fn(function* ({ output }) {
241 yield* zeroTrust
242 .deleteAccessCustomPage({
243 accountId: output.accountId,
244 customPageId: output.customPageId,
245 })
246 .pipe(Effect.catchTag("AccessCustomPageNotFound", () => Effect.void));
247 }),
248 });
249
250const createPageName = (id: string, name: string | undefined) =>
251 Effect.gen(function* () {
252 if (name) return name;
253 return yield* createPhysicalName({ id });
254 });
255
256const findPageByName = (acct: string, name: string) =>
257 zeroTrust.listAccessCustomPages.items({ accountId: acct }).pipe(
258 Stream.filter((p) => p.name === name),
259 Stream.runHead,
260 Effect.map(Option.getOrUndefined),
261 Effect.catch(() => Effect.succeed(undefined)),
262 );
263
264const toAttrs = (observed: ObservedPage, accountId: string) => ({
265 customPageId: observed.uid!,
266 accountId,
267 name: observed.name ?? "",
268 type: (observed.type ?? "forbidden") as CustomPageType,
269});
270
271type ObservedPage = {
272 uid?: string | null;
273 name?: string | null;
274 type?: string | null;
275 customHtml?: string | null;
276};