import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Predicate from "effect/Predicate";
import * as Stream from "effect/Stream";
import { isResolved } from "../../Diff.ts";
import { createPhysicalName } from "../../PhysicalName.ts";
import * as Provider from "../../Provider.ts";
import { Resource } from "../../Resource.ts";
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
import type { Providers } from "../Providers.ts";
export type CustomPageType = "identity_denied" | "forbidden";
export type CustomPageProps = {
/**
* Display name for the custom page. Used as a stable identifier so the
* provider can locate the page during adoption / state recovery. If
* omitted, a unique name is generated from the stack/stage/logical id.
*
* @default ${app}-${stage}-${id}
*/
name?: string;
/**
* The type of Access event the page is shown for. `identity_denied` is
* shown when a user's identity is rejected by policy; `forbidden` is shown
* when access is blocked outright. Changing the type replaces the page.
*/
type: CustomPageType;
/**
* The custom HTML served for the page.
*/
customHtml: string;
};
export type CustomPage = Resource<
"Cloudflare.Access.CustomPage",
CustomPageProps,
{
/** UUID of the custom page assigned by Cloudflare. */
customPageId: string;
/** Cloudflare account that owns the custom page. */
accountId: string;
/** Display name reported by Cloudflare. */
name: string;
/** The Access event type the page is shown for. */
type: CustomPageType;
},
never,
Providers
>;
/**
* A Cloudflare Zero Trust Access custom page. Replaces the default Access
* block pages (`identity_denied` / `forbidden`) with custom HTML, which can
* then be selected on an Access application.
* ### Creating a Custom Page
* **Example:** Custom forbidden page
* ```typescript
* const page = yield* Cloudflare.Access.CustomPage("Forbidden", {
* type: "forbidden",
* customHtml: "
Access denied
",
* });
* ```
*
* **Example:** Custom identity-denied page with an explicit name
* ```typescript
* const page = yield* Cloudflare.Access.CustomPage("Denied", {
* name: "corp-identity-denied",
* type: "identity_denied",
* customHtml: "Who are you?
",
* });
* ```
*
* ### Updating the HTML
* **Example:** HTML and name converge in place
* ```typescript
* const page = yield* Cloudflare.Access.CustomPage("Forbidden", {
* type: "forbidden",
* customHtml: "Still denied
",
* });
* ```
*
* @resource
* @product Access
* @category Cloudflare One (Zero Trust)
*/
export const CustomPage = Resource("Cloudflare.Access.CustomPage");
export const isCustomPage = (value: unknown): value is CustomPage =>
Predicate.hasProperty(value, "Type") &&
value.Type === "Cloudflare.Access.CustomPage";
export const CustomPageProvider = () =>
Provider.succeed(CustomPage, {
stables: ["customPageId", "accountId", "type"],
// Account-scoped collection (pattern b). The list response items already
// carry every Attribute field (uid/name/type) — read's Attributes don't
// include customHtml — so no per-item get is needed to match the read shape.
list: Effect.fn(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
return yield* zeroTrust.listAccessCustomPages.pages({ accountId }).pipe(
Stream.runCollect,
Effect.map((chunk) =>
Array.from(chunk).flatMap((page) =>
(page.result ?? [])
.filter((p): p is typeof p & { uid: string } => p.uid != null)
.map((p) => toAttrs(p, accountId)),
),
),
);
}),
diff: Effect.fn(function* ({ news, output }) {
const { accountId } = yield* yield* CloudflareEnvironment;
if (!isResolved(news)) return undefined;
if ((output?.accountId ?? accountId) !== accountId) {
return { action: "replace" } as const;
}
// The page type cannot change in place.
if (output && news.type !== output.type) {
return { action: "replace" } as const;
}
// name/customHtml converge via PUT.
}),
read: Effect.fn(function* ({ id, output, olds }) {
const { accountId } = yield* yield* CloudflareEnvironment;
const acct = output?.accountId ?? accountId;
if (output?.customPageId) {
const direct = yield* zeroTrust
.getAccessCustomPage({
accountId: acct,
customPageId: output.customPageId,
})
.pipe(
Effect.catchTag("AccessCustomPageNotFound", () =>
Effect.succeed(undefined),
),
);
if (direct && direct.uid) {
return toAttrs(direct, acct);
}
}
const name = yield* createPageName(id, olds?.name ?? output?.name);
const existing = yield* findPageByName(acct, name);
if (!existing || !existing.uid) return undefined;
const full = yield* zeroTrust
.getAccessCustomPage({ accountId: acct, customPageId: existing.uid })
.pipe(
Effect.catchTag("AccessCustomPageNotFound", () =>
Effect.succeed(undefined),
),
);
if (!full || !full.uid) return undefined;
return toAttrs(full, acct);
}),
reconcile: Effect.fn(function* ({ id, news, output }) {
const { accountId } = yield* yield* CloudflareEnvironment;
const acct = output?.accountId ?? accountId;
const name = yield* createPageName(id, news.name);
// Observe — prefer the cached id, fall back to a name lookup so we
// recover from out-of-band deletes and state-persistence failures.
let observed: ObservedPage | undefined;
if (output?.customPageId) {
observed = yield* zeroTrust
.getAccessCustomPage({
accountId: acct,
customPageId: output.customPageId,
})
.pipe(
Effect.catchTag("AccessCustomPageNotFound", () =>
Effect.succeed(undefined),
),
);
}
if (!observed || !observed.uid) {
const byName = yield* findPageByName(acct, name);
if (byName?.uid) {
observed = yield* zeroTrust
.getAccessCustomPage({ accountId: acct, customPageId: byName.uid })
.pipe(
Effect.catchTag("AccessCustomPageNotFound", () =>
Effect.succeed(undefined),
),
);
}
}
// Ensure — create the page when missing. Tolerate a same-named create
// race by re-observing.
if (!observed || !observed.uid) {
const created = yield* zeroTrust
.createAccessCustomPage({
accountId: acct,
name,
type: news.type,
customHtml: news.customHtml,
})
.pipe(
Effect.catch((err) =>
Effect.gen(function* () {
const existing = yield* findPageByName(acct, name);
if (existing && existing.uid) return existing;
return yield* Effect.fail(err);
}),
),
);
if (!created.uid) {
return yield* Effect.fail(
new Error("CustomPage: created page missing uid"),
);
}
return toAttrs({ ...created, type: news.type }, acct);
}
// Sync — converge name/html via PUT only on a real delta. The GET
// returns the full customHtml so the comparison is observed-vs-desired.
if (
observed.name !== name ||
observed.type !== news.type ||
observed.customHtml !== news.customHtml
) {
const updated = yield* zeroTrust.updateAccessCustomPage({
accountId: acct,
customPageId: observed.uid,
name,
type: news.type,
customHtml: news.customHtml,
});
observed = {
uid: updated.uid ?? observed.uid,
name: updated.name ?? name,
type: updated.type ?? news.type,
};
}
return toAttrs(observed, acct);
}),
delete: Effect.fn(function* ({ output }) {
yield* zeroTrust
.deleteAccessCustomPage({
accountId: output.accountId,
customPageId: output.customPageId,
})
.pipe(Effect.catchTag("AccessCustomPageNotFound", () => Effect.void));
}),
});
const createPageName = (id: string, name: string | undefined) =>
Effect.gen(function* () {
if (name) return name;
return yield* createPhysicalName({ id });
});
const findPageByName = (acct: string, name: string) =>
zeroTrust.listAccessCustomPages.items({ accountId: acct }).pipe(
Stream.filter((p) => p.name === name),
Stream.runHead,
Effect.map(Option.getOrUndefined),
Effect.catch(() => Effect.succeed(undefined)),
);
const toAttrs = (observed: ObservedPage, accountId: string) => ({
customPageId: observed.uid!,
accountId,
name: observed.name ?? "",
type: (observed.type ?? "forbidden") as CustomPageType,
});
type ObservedPage = {
uid?: string | null;
name?: string | null;
type?: string | null;
customHtml?: string | null;
};