1import * as images from "@distilled.cloud/cloudflare/images";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5
6import { Unowned } from "../../AdoptPolicy.ts";
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.Images.SigningKey" as const;
15type TypeId = typeof TypeId;
16
17export interface SigningKeyProps {
18 /**
19 * Account the signing key is created in. Defaults to the ambient
20 * Cloudflare account. Changing it triggers a replacement.
21 */
22 accountId?: string;
23 /**
24 * Name of the signing key — the PUT path identifier. If omitted, a unique
25 * name is generated from the app, stage, and logical ID. Changing the
26 * name triggers a replacement.
27 *
28 * @default ${app}-${stage}-${id}
29 */
30 name?: string;
31}
32
33export interface SigningKeyAttributes {
34 /** The signing key's name (its API path identifier). */
35 keyName: string;
36 /** Account the key belongs to. */
37 accountId: string;
38 /** The HMAC key material used to sign image delivery URLs. */
39 value: Redacted.Redacted<string>;
40}
41
42export type SigningKey = Resource<
43 TypeId,
44 SigningKeyProps,
45 SigningKeyAttributes,
46 never,
47 Providers
48>;
49
50/**
51 * A Cloudflare Images signing key — an HMAC key used to generate signed
52 * image delivery URLs (`?sig=` tokens) for images that require signed URLs.
53 *
54 * Cloudflare allows at most **two** keys per account, supporting a
55 * create-second/migrate/delete-first rotation model, and refuses to delete
56 * the last remaining key. Re-PUTting an existing key name **rotates** (i.e.
57 * regenerates) its value, so this resource is existence-only: once the key
58 * exists, redeploys never re-PUT and the key material stays stable.
59 *
60 * Requires the Cloudflare Images subscription; accounts without it receive
61 * the typed `ImagesAccessNotEnabled` error.
62 * ### Creating a Signing Key
63 * **Example:** Key with a generated name
64 * ```typescript
65 * const key = yield* Cloudflare.Images.SigningKey("UrlSigner", {});
66 * ```
67 *
68 * **Example:** Key with an explicit name
69 * ```typescript
70 * const key = yield* Cloudflare.Images.SigningKey("UrlSigner", {
71 * name: "my-app-signer",
72 * });
73 * ```
74 *
75 * ### Using the key
76 * **Example:** Signing image delivery URLs server-side
77 * ```typescript
78 * // The key material is redacted — pass it to your URL signer:
79 * const secret = key.value; // Redacted<string>
80 * ```
81 *
82 * @see https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/
83 *
84 * @resource
85 * @product Images
86 * @category Media
87 */
88export const SigningKey = Resource<SigningKey>(TypeId);
89
90/**
91 * Returns true if the given value is an SigningKey resource.
92 */
93export const isSigningKey = (value: unknown): value is SigningKey =>
94 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
95
96export const SigningKeyProvider = () =>
97 Provider.succeed(SigningKey, {
98 stables: ["keyName", "accountId", "value"],
99
100 diff: Effect.fn(function* ({ olds, news, output }) {
101 // News may still contain unresolved plan-time expressions — defer to
102 // the engine's default update logic until everything is concrete.
103 if (!isResolved(news)) return undefined;
104 // The key name is its PUT path identifier — it cannot be renamed.
105 // Only compare when the old name is knowable; a generated name is
106 // stable across deploys so an omitted name never replaces.
107 const oldName = output?.keyName ?? olds?.name;
108 if (
109 oldName !== undefined &&
110 news.name !== undefined &&
111 news.name !== oldName
112 ) {
113 return { action: "replace" } as const;
114 }
115 if (
116 typeof olds?.accountId === "string" &&
117 typeof news.accountId === "string" &&
118 olds.accountId !== news.accountId
119 ) {
120 return { action: "replace" } as const;
121 }
122 if (
123 output?.accountId !== undefined &&
124 typeof news.accountId === "string" &&
125 news.accountId !== output.accountId
126 ) {
127 return { action: "replace" } as const;
128 }
129 return undefined;
130 }),
131
132 read: Effect.fn(function* ({ id, output, olds }) {
133 const { accountId } = yield* yield* CloudflareEnvironment;
134 const acct =
135 output?.accountId ??
136 (olds?.accountId as string | undefined) ??
137 accountId;
138 const name = output?.keyName ?? (yield* createKeyName(id, olds?.name));
139
140 const observed = yield* findKey(acct, name);
141 if (!observed) return undefined;
142 const attrs = toAttributes(observed, acct);
143 // Signing keys carry no ownership markers. With no prior output we
144 // cannot prove we created a same-named key — report it `Unowned` so
145 // the engine gates takeover behind the adopt policy.
146 return output?.keyName ? attrs : Unowned(attrs);
147 }),
148
149 // Account-scoped collection (Cloudflare caps it at two keys per
150 // account). The non-paginated `listV1Keys` returns the whole set in one
151 // call; map each entry to the same Attributes shape `read` produces.
152 // Accounts without the Images signing-keys entitlement have no keys to
153 // enumerate — treat as an empty set rather than a hard failure.
154 list: Effect.fn(function* () {
155 const { accountId } = yield* yield* CloudflareEnvironment;
156 return yield* images.listV1Keys({ accountId }).pipe(
157 Effect.map((response): SigningKeyAttributes[] =>
158 (response.keys ?? []).map((key) => toAttributes(key, accountId)),
159 ),
160 Effect.catchTag("ImagesAccessNotEnabled", () =>
161 Effect.succeed<SigningKeyAttributes[]>([]),
162 ),
163 );
164 }),
165
166 reconcile: Effect.fn(function* ({ id, news }) {
167 const { accountId } = yield* yield* CloudflareEnvironment;
168 // Inputs have been resolved to concrete strings by Plan.
169 const acct = (news.accountId as string | undefined) ?? accountId;
170 const name = yield* createKeyName(id, news.name);
171
172 // 1. Observe — list the account's keys and look for ours. `output`
173 // is only a cache of the name; the list is authoritative.
174 let observed = yield* findKey(acct, name);
175
176 // 2. Ensure — existence-only resource: PUT only when the key is
177 // missing. Re-PUTting an existing name would ROTATE the key
178 // material out from under consumers, so a present key is final —
179 // there is no sync step.
180 if (!observed) {
181 const created = yield* images.putV1Key({
182 accountId: acct,
183 signingKeyName: name,
184 });
185 observed =
186 created.keys?.find((key) => key.name === name) ??
187 (yield* findKey(acct, name));
188 }
189 if (!observed?.value) {
190 // The PUT response and the follow-up list both failed to surface
191 // the key — eventual-consistency blip; fail typed so the engine
192 // can retry the reconcile.
193 return yield* Effect.fail(
194 new images.KeyNotFound({
195 code: 5404,
196 message: `signing key ${name} not observable after create`,
197 }),
198 );
199 }
200
201 return toAttributes(observed, acct);
202 }),
203
204 delete: Effect.fn(function* ({ output }) {
205 // Note: Cloudflare refuses to delete the last remaining signing key —
206 // that error is surfaced as-is (non-retryable) rather than swallowed.
207 yield* images
208 .deleteV1Key({
209 accountId: output.accountId,
210 signingKeyName: output.keyName,
211 })
212 .pipe(Effect.catchTag("KeyNotFound", () => Effect.void));
213 }),
214 });
215
216type ObservedKey = { name?: string | null; value?: string | null };
217
218/**
219 * Find a signing key by exact name in the account's key list. Returns
220 * `undefined` when absent.
221 */
222const findKey = (accountId: string, name: string) =>
223 images
224 .listV1Keys({ accountId })
225 .pipe(
226 Effect.map((response) =>
227 (response.keys ?? []).find(
228 (key): key is ObservedKey => key.name === name,
229 ),
230 ),
231 );
232
233const createKeyName = (id: string, name: string | undefined) =>
234 Effect.gen(function* () {
235 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
236 });
237
238const toAttributes = (
239 key: ObservedKey,
240 accountId: string,
241): SigningKeyAttributes => ({
242 keyName: key.name ?? "",
243 accountId,
244 value: Redacted.make(key.value ?? ""),
245});