1import * as accounts from "@distilled.cloud/cloudflare/accounts";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 buildConditionPayload,
13 collectPolicies,
14 conditionFingerprint,
15 policyFingerprint,
16 resolvePolicies,
17 type ApiTokenBinding,
18 type Props,
19} from "./Common.ts";
20
21export type AccountApiToken = Resource<
22 "Cloudflare.ApiToken.AccountApiToken",
23 Props,
24 {
25 tokenId: string;
26 name: string;
27 status: "active" | "disabled" | "expired";
28 /**
29 * The plaintext token value. Cloudflare returns this only once, on
30 * creation, so we persist it here for downstream consumers (e.g. a
31 * GitHub Actions secret).
32 */
33 value: Redacted.Redacted<string>;
34 accountId: string;
35 },
36 ApiTokenBinding,
37 Providers
38>;
39
40/**
41 * A Cloudflare account-owned API token (`POST /accounts/{account_id}/tokens`).
42 *
43 * Account-owned tokens are managed at the account level and persist
44 * independently of any single user. Use these for CI tokens, third-party
45 * integrations, or anywhere the token should outlive an individual user's
46 * session.
47 *
48 * Creating account-owned tokens requires the caller to have the
49 * `API Tokens > Write` account permission.
50 * ### Creating a Token
51 * **Example:** A token for managing Workers and KV from CI
52 * ```typescript
53 * // the account ID resolves from the profile you're deploying with
54 * const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment;
55 *
56 * const token = yield* Cloudflare.ApiToken.AccountApiToken("ci-token", {
57 * name: "my-ci-token",
58 * accountId,
59 * policies: [
60 * {
61 * effect: "allow",
62 * permissionGroups: [
63 * "Workers Scripts Write",
64 * "Workers KV Storage Write",
65 * ],
66 * resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
67 * },
68 * ],
69 * });
70 *
71 * yield* GitHub.Secret("cf-api-token", {
72 * owner: "me",
73 * repository: "my-repo",
74 * name: "CLOUDFLARE_API_TOKEN",
75 * value: token.value,
76 * });
77 * ```
78 *
79 * ### Attaching Policies via Bindings
80 * **Example:** Let a downstream capability contribute its own policies
81 * A token can be created with no `policies` of its own; the policies are
82 * supplied through its binding contract (see {@link ApiTokenBinding}). This is
83 * how capabilities like {@link CreateTunnel} provision a least-privilege token.
84 * ```typescript
85 * const token = yield* Cloudflare.ApiToken.AccountApiToken("scoped-token");
86 *
87 * yield* token.bind("MyCapability", {
88 * policies: [
89 * {
90 * effect: "allow",
91 * permissionGroups: ["Cloudflare Tunnel Write"],
92 * resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
93 * },
94 * ],
95 * });
96 * ```
97 *
98 * ### Exposing a Token to a Worker
99 * **Example:** Read the token value at runtime
100 * Bind the token's outputs in the Worker's Init phase to get runtime
101 * accessors. Binding `token.value` injects it as a `secret_text` Worker
102 * binding; the returned accessor reads it back (as `Redacted`) at runtime.
103 * ```typescript
104 * // init
105 * const value = yield* token.value; // Accessor<Redacted<string>>
106 * const accountId = yield* token.accountId; // Accessor<string>
107 *
108 * return {
109 * fetch: Effect.gen(function* () {
110 * const apiToken = yield* value; // Redacted<string>
111 * // ... call the Cloudflare API with `apiToken`
112 * return HttpServerResponse.text("ok");
113 * }),
114 * };
115 * ```
116 *
117 * @resource
118 * @product API Tokens
119 * @category Account & Identity
120 */
121export const AccountApiToken = Resource<AccountApiToken>(
122 "Cloudflare.ApiToken.AccountApiToken",
123 { aliases: ["Cloudflare.AccountApiToken"] },
124);
125
126type AccountApiTokenAttributes = AccountApiToken["Attributes"];
127
128const resolveName = (id: string, name: string | undefined) =>
129 Effect.gen(function* () {
130 return name ?? (yield* createPhysicalName({ id }));
131 });
132
133const buildAttributes = (
134 tokenData: {
135 id?: string | null;
136 name?: string | null;
137 // Distilled widened generated string enums to open unions (`string & {}`).
138 status?: string | null;
139 },
140 value: Redacted.Redacted<string>,
141 accountId: string,
142): AccountApiTokenAttributes => ({
143 tokenId: tokenData.id ?? "",
144 name: tokenData.name ?? "",
145 status: (tokenData.status ?? "active") as "active" | "disabled" | "expired",
146 value,
147 accountId,
148});
149
150export const AccountApiTokenProvider = () =>
151 Provider.succeed(AccountApiToken, {
152 stables: ["tokenId", "accountId"],
153 diff: Effect.fn(function* ({ id, olds, news = {}, output }) {
154 if (!isResolved(news)) return undefined;
155 const { accountId: defaultAccountId } =
156 yield* yield* CloudflareEnvironment;
157
158 const newAccountId = news.accountId ?? defaultAccountId;
159 const oldAccountId =
160 output?.accountId ?? olds?.accountId ?? defaultAccountId;
161 if (oldAccountId !== newAccountId) {
162 return { action: "replace" } as const;
163 }
164 const oldName = output?.name ?? (yield* resolveName(id, olds?.name));
165 const newName = yield* resolveName(id, news.name);
166 const oldPolicyFp = policyFingerprint(
167 resolvePolicies(olds?.policies ?? []),
168 );
169 const newPolicyFp = policyFingerprint(
170 resolvePolicies(news.policies ?? []),
171 );
172 const oldCondFp = conditionFingerprint(olds?.condition);
173 const newCondFp = conditionFingerprint(news.condition);
174 if (
175 oldName !== newName ||
176 oldPolicyFp !== newPolicyFp ||
177 oldCondFp !== newCondFp ||
178 (olds?.expiresOn ?? undefined) !== (news.expiresOn ?? undefined) ||
179 (olds?.notBefore ?? undefined) !== (news.notBefore ?? undefined)
180 ) {
181 return { action: "update" } as const;
182 }
183 }),
184 reconcile: Effect.fn(function* ({ id, news = {}, output, bindings }) {
185 const { accountId: defaultAccountId } =
186 yield* yield* CloudflareEnvironment;
187 const accountId = news.accountId ?? defaultAccountId;
188 const name = yield* resolveName(id, news.name);
189 const collected = collectPolicies(news.policies, bindings);
190 if (collected.length === 0) {
191 return yield* Effect.die(
192 `Cloudflare requires at least one policy on token "${name}". ` +
193 "Pass `policies` or attach them via a binding.",
194 );
195 }
196 const policies = resolvePolicies(collected);
197
198 // Observe — fetch current state if we already know the token id.
199 // Cloudflare reports a deleted token as `InvalidRoute` or
200 // `TokenNotFound`; both mean "create from scratch".
201 const observed = output?.tokenId
202 ? yield* accounts
203 .getToken({
204 accountId: output.accountId,
205 tokenId: output.tokenId,
206 })
207 .pipe(
208 Effect.map((token) => token),
209 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
210 Effect.catchTag("TokenNotFound", () => Effect.succeed(undefined)),
211 )
212 : undefined;
213
214 // Ensure — create if missing. Cloudflare returns the plaintext
215 // token value exactly once on create, so we must persist it for
216 // downstream consumers. There is no idempotency token here; if
217 // a stale write produced an orphan we accept the duplicate over
218 // the alternative of losing the secret value.
219 if (observed === undefined) {
220 const result = yield* accounts.createToken({
221 accountId,
222 name,
223 policies,
224 condition: buildConditionPayload(news.condition),
225 expiresOn: news.expiresOn,
226 notBefore: news.notBefore,
227 });
228 if (!result.value) {
229 return yield* Effect.die(
230 `Cloudflare did not return a value for token "${name}".`,
231 );
232 }
233 return buildAttributes(result, Redacted.make(result.value), accountId);
234 }
235
236 // Sync — the update API replaces all mutable fields (name,
237 // policies, condition, validity window). Cloudflare does not
238 // return the plaintext value on update, so we preserve the one
239 // captured at creation.
240 const result = yield* accounts.updateToken({
241 accountId,
242 tokenId: output!.tokenId,
243 name,
244 policies,
245 condition: buildConditionPayload(news.condition),
246 expiresOn: news.expiresOn,
247 notBefore: news.notBefore,
248 });
249 return buildAttributes(result, output!.value, accountId);
250 }),
251 delete: Effect.fn(function* ({ output }) {
252 yield* accounts
253 .deleteToken({
254 accountId: output.accountId,
255 tokenId: output.tokenId,
256 })
257 .pipe(
258 // Already gone — Cloudflare may report this as either an
259 // `InvalidRoute` (token-id no longer routable) or a generic
260 // `TokenNotFound`. Either is fine; we just want the resource gone.
261 Effect.catchTag("InvalidRoute", () => Effect.void),
262 Effect.catchTag("TokenNotFound", () => Effect.void),
263 // Cloudflare-managed tokens (e.g. "Cloudflare Resource Tagging
264 // System") can never be deleted (code 1001). We don't own them, so
265 // treat the refusal as a no-op rather than a failure.
266 Effect.catchTag("TokenManagedByCloudflare", () => Effect.void),
267 );
268 }),
269 list: Effect.fn(function* () {
270 const { accountId } = yield* yield* CloudflareEnvironment;
271 // The plaintext token value is only returned by Cloudflare once, at
272 // creation time. The list/get APIs never expose it, so we hydrate an
273 // empty redacted placeholder — matching what `read` returns when it
274 // re-observes a token whose value we no longer hold.
275 return yield* accounts.listTokens.pages({ accountId }).pipe(
276 Stream.runCollect,
277 Effect.map((chunk) =>
278 Array.from(chunk).flatMap((page) =>
279 (page.result ?? []).map((token) =>
280 buildAttributes(token, Redacted.make(""), accountId),
281 ),
282 ),
283 ),
284 );
285 }),
286 read: Effect.fn(function* ({ output }) {
287 if (!output?.tokenId) return undefined;
288 return yield* accounts
289 .getToken({
290 accountId: output.accountId,
291 tokenId: output.tokenId,
292 })
293 .pipe(
294 Effect.map((token) =>
295 buildAttributes(token, output.value, output.accountId),
296 ),
297 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
298 Effect.catchTag("TokenNotFound", () => Effect.succeed(undefined)),
299 );
300 }),
301 });