1import * as user from "@distilled.cloud/cloudflare/user";
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 type { Providers } from "../Providers.ts";
10import {
11 buildConditionPayload,
12 collectPolicies,
13 conditionFingerprint,
14 policyFingerprint,
15 resolvePolicies,
16 type ApiTokenBinding,
17 type Props,
18} from "./Common.ts";
19
20export type UserApiToken = Resource<
21 "Cloudflare.ApiToken.UserApiToken",
22 Props,
23 {
24 tokenId: string;
25 name: string;
26 status: "active" | "disabled" | "expired";
27 /**
28 * The plaintext token value. Cloudflare returns this only once, on
29 * creation, so we persist it here for downstream consumers.
30 */
31 value: Redacted.Redacted<string>;
32 },
33 ApiTokenBinding,
34 Providers
35>;
36
37/**
38 * A Cloudflare user-owned API token (`POST /user/tokens`).
39 *
40 * User-owned tokens are tied to the authenticated user's identity. They can
41 * be created by any authenticated user (including OAuth-derived sessions
42 * from `alchemy profile edit`) without needing the account-level
43 * `API Tokens > Write` permission, but they are also revoked if the user
44 * leaves the account.
45 *
46 * For CI tokens, prefer {@link AccountApiToken} so the token survives
47 * personnel changes.
48 *
49 * Policy `resources` are passed through verbatim — no `accountId` rewriting
50 * is performed because user tokens aren't bound to a single account.
51 * ### Creating a Token
52 * **Example:** A token bound to the authenticated user
53 * ```typescript
54 * const token = yield* Cloudflare.ApiToken.UserApiToken("personal-token", {
55 * name: "my-personal-token",
56 * policies: [
57 * {
58 * effect: "allow",
59 * permissionGroups: ["Workers Scripts Read"],
60 * resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
61 * },
62 * ],
63 * });
64 * ```
65 *
66 * ### Attaching Policies via Bindings
67 * **Example:** Let a downstream capability contribute its own policies
68 * A token can be created with no `policies` of its own; the policies are
69 * supplied through its binding contract (see {@link ApiTokenBinding}).
70 * ```typescript
71 * const token = yield* Cloudflare.ApiToken.UserApiToken("scoped-token");
72 *
73 * yield* token.bind("MyCapability", {
74 * policies: [
75 * {
76 * effect: "allow",
77 * permissionGroups: ["Workers Scripts Read"],
78 * resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
79 * },
80 * ],
81 * });
82 * ```
83 *
84 * ### Exposing a Token to a Worker
85 * **Example:** Read the token value at runtime
86 * Bind the token's value output in the Worker's Init phase to get a runtime
87 * accessor. Binding it injects a `secret_text` Worker binding; the returned
88 * accessor reads it back (as `Redacted`) at runtime.
89 * ```typescript
90 * // init
91 * const value = yield* token.value; // Accessor<Redacted<string>>
92 *
93 * return {
94 * fetch: Effect.gen(function* () {
95 * const apiToken = yield* value; // Redacted<string>
96 * // ... call the Cloudflare API with `apiToken`
97 * return HttpServerResponse.text("ok");
98 * }),
99 * };
100 * ```
101 *
102 * @resource
103 * @product API Tokens
104 * @category Account & Identity
105 */
106export const UserApiToken = Resource<UserApiToken>(
107 "Cloudflare.ApiToken.UserApiToken",
108 { aliases: ["Cloudflare.UserApiToken"] },
109);
110
111type UserApiTokenAttributes = UserApiToken["Attributes"];
112
113export const UserApiTokenProvider = () =>
114 Provider.succeed(UserApiToken, {
115 stables: ["tokenId"],
116 diff: Effect.fn(function* ({ id, olds, news = {}, output }) {
117 if (!isResolved(news)) return undefined;
118 const oldName = output?.name ?? (yield* resolveName(id, olds?.name));
119 const newName = yield* resolveName(id, news.name);
120 const oldPolicyFp = policyFingerprint(
121 resolvePolicies(olds?.policies ?? []),
122 );
123 const newPolicyFp = policyFingerprint(
124 resolvePolicies(news.policies ?? []),
125 );
126 const oldCondFp = conditionFingerprint(olds?.condition);
127 const newCondFp = conditionFingerprint(news.condition);
128 if (
129 oldName !== newName ||
130 oldPolicyFp !== newPolicyFp ||
131 oldCondFp !== newCondFp ||
132 (olds?.expiresOn ?? undefined) !== (news.expiresOn ?? undefined) ||
133 (olds?.notBefore ?? undefined) !== (news.notBefore ?? undefined)
134 ) {
135 return { action: "update" } as const;
136 }
137 }),
138 reconcile: Effect.fn(function* ({ id, news = {}, output, bindings }) {
139 const name = yield* resolveName(id, news.name);
140 const collected = collectPolicies(news.policies, bindings);
141 if (collected.length === 0) {
142 return yield* Effect.die(
143 `Cloudflare requires at least one policy on token "${name}". ` +
144 "Pass `policies` or attach them via a binding.",
145 );
146 }
147 const policies = resolvePolicies(collected);
148
149 // Observe — fetch current state if we know the token id;
150 // Cloudflare reports a deleted token as `TokenNotFound`, which
151 // we treat as "create from scratch".
152 const observed = output?.tokenId
153 ? yield* user.getToken({ tokenId: output.tokenId }).pipe(
154 Effect.map((token) => token),
155 Effect.catchTag("TokenNotFound", () => Effect.succeed(undefined)),
156 )
157 : undefined;
158
159 // Ensure — create if missing. Cloudflare returns the plaintext
160 // token value exactly once on create, so we must persist it.
161 // No idempotency token is available; we accept a duplicate over
162 // losing the secret value.
163 if (observed === undefined) {
164 const result = yield* user.createToken({
165 name,
166 policies,
167 condition: buildConditionPayload(news.condition),
168 expiresOn: news.expiresOn,
169 notBefore: news.notBefore,
170 });
171 if (!result.value) {
172 return yield* Effect.die(
173 `Cloudflare did not return a value for token "${name}".`,
174 );
175 }
176 return buildAttributes(result, Redacted.make(result.value));
177 }
178
179 // Sync — the update API replaces all mutable fields. Cloudflare
180 // does not return the plaintext value on update, so preserve
181 // the one captured at creation.
182 const result = yield* user.updateToken({
183 tokenId: output!.tokenId,
184 name,
185 policies,
186 condition: buildConditionPayload(news.condition),
187 expiresOn: news.expiresOn,
188 notBefore: news.notBefore,
189 });
190 return buildAttributes(result, output!.value);
191 }),
192 delete: Effect.fn(function* ({ output }) {
193 yield* user
194 .deleteToken({ tokenId: output.tokenId })
195 .pipe(Effect.catchTag("TokenNotFound", () => Effect.void));
196 }),
197 read: Effect.fn(function* ({ output }) {
198 if (!output?.tokenId) return undefined;
199 return yield* user.getToken({ tokenId: output.tokenId }).pipe(
200 Effect.map((token) => buildAttributes(token, output.value)),
201 Effect.catchTag("TokenNotFound", () => Effect.succeed(undefined)),
202 );
203 }),
204 // User-scoped: enumerate every token owned by the authenticated user via
205 // `GET /user/tokens` (no account scope). The list API never returns the
206 // plaintext token value — it is only emitted once at creation — so we
207 // hydrate the read shape with an empty Redacted value, matching what
208 // `read` produces when the secret was never captured.
209 list: () =>
210 user.listTokens.pages({}).pipe(
211 Stream.runCollect,
212 Effect.map((chunk) =>
213 Array.from(chunk).flatMap((page) =>
214 (page.result ?? []).map((token) =>
215 buildAttributes(token, Redacted.make("")),
216 ),
217 ),
218 ),
219 // User-scoped tokens require user-level auth; an account-scoped token
220 // (e.g. a CI profile) gets `Unauthorized` here — nothing to enumerate.
221 Effect.catchTag("Unauthorized", () => Effect.succeed([])),
222 ),
223 });
224
225const resolveName = (id: string, name: string | undefined) =>
226 Effect.gen(function* () {
227 return name ?? (yield* createPhysicalName({ id }));
228 });
229const buildAttributes = (
230 tokenData: {
231 id?: string | null;
232 name?: string | null;
233 // Distilled widened generated string enums to open unions (`string & {}`).
234 status?: string | null;
235 },
236 value: Redacted.Redacted<string>,
237): UserApiTokenAttributes => ({
238 tokenId: tokenData.id ?? "",
239 name: tokenData.name ?? "",
240 status: (tokenData.status ?? "active") as "active" | "disabled" | "expired",
241 value,
242});