1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as crypto from "node:crypto";
3
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Predicate from "effect/Predicate";
7import * as Stream from "effect/Stream";
8
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
14import type { Providers } from "../Providers.ts";
15
16export type BookmarkProps = {
17 /**
18 * The name of the bookmark application shown in the App Launcher. Used as
19 * a stable identifier so the provider can locate the bookmark during
20 * adoption / state recovery. If omitted, a unique name is generated from
21 * the stack/stage/logical id.
22 *
23 * @default ${app}-${stage}-${id}
24 */
25 name?: string;
26 /**
27 * The domain the bookmark links to, e.g. `example.com` or
28 * `wiki.example.com/path`.
29 */
30 domain: string;
31 /**
32 * The image URL for the logo shown in the App Launcher dashboard.
33 */
34 logoUrl?: string;
35 /**
36 * Whether to display the bookmark in the App Launcher.
37 *
38 * @default true
39 */
40 appLauncherVisible?: boolean;
41};
42
43export type Bookmark = Resource<
44 "Cloudflare.Access.Bookmark",
45 BookmarkProps,
46 {
47 /** UUID of the bookmark application. */
48 bookmarkId: string;
49 /** Cloudflare account that owns the bookmark. */
50 accountId: string;
51 /** Display name reported by Cloudflare. */
52 name: string;
53 /** The domain the bookmark links to. */
54 domain: string;
55 /** The logo URL shown in the App Launcher. */
56 logoUrl: string | undefined;
57 /** Whether the bookmark is displayed in the App Launcher. */
58 appLauncherVisible: boolean;
59 },
60 never,
61 Providers
62>;
63
64/**
65 * A Cloudflare Zero Trust Access bookmark application — an unprotected link
66 * shown in the App Launcher.
67 * @deprecated **Legacy resource.** Cloudflare has deprecated the dedicated
68 * bookmarks API in favor of Access applications with `type: "bookmark"` —
69 * prefer {@link Application} for new configurations. This resource is
70 * provided for managing pre-existing bookmark records.
71 *
72 * ### Creating a Bookmark
73 * **Example:** Basic bookmark
74 * ```typescript
75 * const bookmark = yield* Cloudflare.Access.Bookmark("Wiki", {
76 * domain: "wiki.example.com",
77 * });
78 * ```
79 *
80 * **Example:** Bookmark with a logo, hidden from the App Launcher
81 * ```typescript
82 * const bookmark = yield* Cloudflare.Access.Bookmark("Wiki", {
83 * name: "internal-wiki",
84 * domain: "wiki.example.com",
85 * logoUrl: "https://example.com/logo.png",
86 * appLauncherVisible: false,
87 * });
88 * ```
89 *
90 * ### Preferred Alternative
91 * **Example:** Bookmark-type Access application (non-legacy)
92 * ```typescript
93 * const app = yield* Cloudflare.Access.Application("Wiki", {
94 * type: "bookmark",
95 * domain: "wiki.example.com",
96 * });
97 * ```
98 *
99 * @resource
100 * @product Access
101 * @category Cloudflare One (Zero Trust)
102 */
103export const Bookmark = Resource<Bookmark>("Cloudflare.Access.Bookmark");
104
105export const isBookmark = (value: unknown): value is Bookmark =>
106 Predicate.hasProperty(value, "Type") &&
107 value.Type === "Cloudflare.Access.Bookmark";
108
109export const BookmarkProvider = () =>
110 Provider.succeed(Bookmark, {
111 stables: ["bookmarkId", "accountId"],
112 diff: Effect.fn(function* ({ news, output }) {
113 const { accountId } = yield* yield* CloudflareEnvironment;
114 if (!isResolved(news)) return undefined;
115 if ((output?.accountId ?? accountId) !== accountId) {
116 return { action: "replace" } as const;
117 }
118 // name/domain/logoUrl/appLauncherVisible converge via PUT.
119 }),
120 read: Effect.fn(function* ({ id, output, olds }) {
121 const { accountId } = yield* yield* CloudflareEnvironment;
122 const acct = output?.accountId ?? accountId;
123 if (output?.bookmarkId) {
124 const direct = yield* getBookmark(acct, output.bookmarkId);
125 if (direct && direct.id) return toAttrs(direct, acct);
126 }
127 const name = yield* createBookmarkName(id, output?.name ?? olds?.name);
128 const existing = yield* findBookmarkByName(acct, name);
129 if (!existing || !existing.id) return undefined;
130 return toAttrs(existing, acct);
131 }),
132 reconcile: Effect.fn(function* ({ id, news, output }) {
133 const { accountId } = yield* yield* CloudflareEnvironment;
134 const acct = output?.accountId ?? accountId;
135 const name = yield* createBookmarkName(id, news.name);
136 const desiredVisible = news.appLauncherVisible ?? true;
137
138 // Observe — prefer the cached id, fall back to a name lookup so we
139 // recover from out-of-band deletes and state-persistence failures.
140 let observed: ObservedBookmark | undefined;
141 if (output?.bookmarkId) {
142 observed = yield* getBookmark(acct, output.bookmarkId);
143 }
144 if (!observed || !observed.id) {
145 observed = yield* findBookmarkByName(acct, name);
146 }
147
148 // Ensure — create when missing. The legacy API requires the new
149 // bookmark's UUID in the path, so derive a deterministic one from the
150 // name: a retry after a partial failure converges on the same record.
151 if (!observed || !observed.id) {
152 const bookmarkId = yield* deterministicUuid(`${acct}:${name}`);
153 const created = yield* zeroTrust
154 .createAccessBookmark({
155 accountId: acct,
156 bookmarkId,
157 name,
158 domain: news.domain,
159 logoUrl: news.logoUrl,
160 appLauncherVisible: desiredVisible,
161 })
162 .pipe(
163 Effect.catch((err) =>
164 Effect.gen(function* () {
165 const existing = yield* getBookmark(acct, bookmarkId);
166 if (existing && existing.id) return existing;
167 return yield* Effect.fail(err);
168 }),
169 ),
170 );
171 if (!created.id) {
172 return yield* Effect.fail(
173 new Error("Bookmark: created bookmark missing id"),
174 );
175 }
176 return toAttrs(created, acct);
177 }
178
179 // Sync — PUT the full desired shape only on a real delta.
180 if (
181 observed.name !== name ||
182 observed.domain !== news.domain ||
183 (observed.logoUrl ?? undefined) !== news.logoUrl ||
184 (observed.appLauncherVisible ?? true) !== desiredVisible
185 ) {
186 const updated = yield* zeroTrust.updateAccessBookmark({
187 accountId: acct,
188 bookmarkId: observed.id,
189 name,
190 domain: news.domain,
191 logoUrl: news.logoUrl,
192 appLauncherVisible: desiredVisible,
193 });
194 observed = {
195 id: updated.id ?? observed.id,
196 name: updated.name ?? name,
197 domain: updated.domain ?? news.domain,
198 logoUrl: updated.logoUrl ?? news.logoUrl,
199 appLauncherVisible: updated.appLauncherVisible ?? desiredVisible,
200 };
201 }
202
203 return toAttrs(observed, acct);
204 }),
205 delete: Effect.fn(function* ({ output }) {
206 yield* zeroTrust
207 .deleteAccessBookmark({
208 accountId: output.accountId,
209 bookmarkId: output.bookmarkId,
210 })
211 .pipe(Effect.catchTag("AccessBookmarkNotFound", () => Effect.void));
212 }),
213 list: Effect.fn(function* () {
214 const { accountId } = yield* yield* CloudflareEnvironment;
215 return yield* zeroTrust.listAccessBookmarks.pages({ accountId }).pipe(
216 Stream.runCollect,
217 Effect.map((chunk) =>
218 Array.from(chunk).flatMap((page) =>
219 (page.result ?? [])
220 .filter((b): b is ObservedBookmark & { id: string } =>
221 Predicate.isNotNullish(b.id),
222 )
223 .map((b) => toAttrs(b, accountId)),
224 ),
225 ),
226 );
227 }),
228 });
229
230const createBookmarkName = (id: string, name: string | undefined) =>
231 Effect.gen(function* () {
232 if (name) return name;
233 return yield* createPhysicalName({ id });
234 });
235
236const getBookmark = (acct: string, bookmarkId: string) =>
237 zeroTrust
238 .getAccessBookmark({ accountId: acct, bookmarkId })
239 .pipe(
240 Effect.catchTag("AccessBookmarkNotFound", () =>
241 Effect.succeed(undefined),
242 ),
243 );
244
245const findBookmarkByName = (acct: string, name: string) =>
246 zeroTrust.listAccessBookmarks.items({ accountId: acct }).pipe(
247 Stream.filter((b): b is ObservedBookmark => b.name === name),
248 Stream.runHead,
249 Effect.map(Option.getOrUndefined),
250 Effect.catch(() => Effect.succeed(undefined)),
251 );
252
253/** RFC-4122-shaped v4 UUID derived deterministically from a seed string. */
254const deterministicUuid = (seed: string) =>
255 Effect.sync(() => {
256 const hex = crypto.createHash("sha256").update(seed).digest("hex");
257 const bytes = hex.slice(0, 32).split("");
258 bytes[12] = "4";
259 bytes[16] = ((parseInt(bytes[16]!, 16) & 0x3) | 0x8).toString(16);
260 const s = bytes.join("");
261 return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
262 });
263
264const toAttrs = (observed: ObservedBookmark, accountId: string) => ({
265 bookmarkId: observed.id!,
266 accountId,
267 name: observed.name ?? "",
268 domain: observed.domain ?? "",
269 logoUrl: observed.logoUrl ?? undefined,
270 appLauncherVisible: observed.appLauncherVisible ?? true,
271});
272
273type ObservedBookmark = {
274 id?: string;
275 name?: string | null;
276 domain?: string | null;
277 logoUrl?: string | null;
278 appLauncherVisible?: boolean | null;
279};