1import * as accounts from "@distilled.cloud/cloudflare/accounts";
2import {
3 apiKeyCredentials,
4 apiTokenCredentials,
5} from "@distilled.cloud/cloudflare/Credentials";
6import * as user from "@distilled.cloud/cloudflare/user";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import {
11 selectableScopes,
12 tokenPolicies,
13 type PermissionGroup,
14 type TokenPolicy,
15} from "../../Cloudflare/Auth/TokenPolicy.ts";
16import * as CloudflareCredentials from "../../Cloudflare/Credentials.ts";
17import { AlchemistInvalidInput, type Diagnostic } from "../Errors.ts";
18
19export interface GlobalCredentials {
20 readonly email: string;
21 readonly apiKey: Redacted.Redacted<string>;
22}
23
24export interface Account {
25 readonly id: string;
26 readonly name: string;
27}
28
29export type {
30 PermissionGroup,
31 TokenPolicy,
32} from "../../Cloudflare/Auth/TokenPolicy.ts";
33
34export interface TokenCatalog {
35 readonly accounts: ReadonlyArray<Account>;
36 readonly permissionGroups: ReadonlyArray<PermissionGroup>;
37}
38
39export interface PlanInput {
40 readonly credentials: GlobalCredentials;
41 readonly name: string;
42 readonly accountIds: ReadonlyArray<string>;
43 readonly permissionGroupIds: ReadonlyArray<string> | "all";
44}
45
46export interface TokenPlan {
47 readonly name: string;
48 readonly accountIds: ReadonlyArray<string>;
49 readonly permissionGroupIds: ReadonlyArray<string>;
50 readonly permissionCount: number;
51 readonly grantsFullAccess: boolean;
52 readonly policies: ReadonlyArray<TokenPolicy>;
53}
54
55export interface CreateInput {
56 readonly credentials: GlobalCredentials;
57 readonly plan: TokenPlan;
58}
59
60export interface CreatedToken {
61 readonly id: string;
62 readonly name: string;
63 readonly value: Redacted.Redacted<string>;
64 readonly grantedPermissionGroups: number;
65 /** Echoed back by Cloudflare; may contain effects we never request. */
66 readonly policies: ReadonlyArray<unknown>;
67 readonly verificationStatus?: string;
68 readonly diagnostics: ReadonlyArray<Diagnostic>;
69}
70
71/** Cloudflare accepted the create call but returned no usable token. */
72export class CloudflareTokenError extends Data.TaggedError(
73 "CloudflareTokenError",
74)<{ readonly message: string }> {}
75
76/** Authenticate the surrounding effect with the user's Global API Key. */
77const withGlobalKey = (credentials: GlobalCredentials) =>
78 Effect.provideService(
79 CloudflareCredentials.Credentials,
80 Effect.succeed(
81 apiKeyCredentials({
82 apiKey: Redacted.value(credentials.apiKey),
83 email: credentials.email,
84 }),
85 ),
86 );
87
88/** The accounts and permission groups a token can be scoped to. */
89export const catalog = Effect.fn("Alchemist.cloudflare.token.catalog")(
90 // The credentials only authenticate the call, via the transformer below.
91 function* (_credentials: GlobalCredentials) {
92 const listAccounts = yield* accounts.listAccounts;
93 const listPermissionGroups = yield* user.listTokenPermissionGroups;
94 const { accountResponse, permissionGroupResponse } = yield* Effect.all(
95 {
96 accountResponse: listAccounts({}),
97 permissionGroupResponse: listPermissionGroups({}),
98 },
99 { concurrency: "unbounded" },
100 );
101 return {
102 accounts: accountResponse.result.map(({ id, name }) => ({
103 id,
104 name,
105 })),
106 permissionGroups: permissionGroupResponse.result.flatMap((group) =>
107 group.id && group.name && group.scopes?.length
108 ? [
109 {
110 id: group.id,
111 name: group.name,
112 category: group.category ?? undefined,
113 scopes: group.scopes,
114 selectable: selectableScopes.has(group.scopes[0]!),
115 },
116 ]
117 : [],
118 ),
119 } satisfies TokenCatalog;
120 },
121 (effect, credentials) => withGlobalKey(credentials)(effect),
122);
123
124/** Resolve the selected permission groups into concrete token policies. */
125export const plan = Effect.fn("Alchemist.cloudflare.token.plan")(function* (
126 input: PlanInput,
127) {
128 const tokenCatalog = yield* catalog(input.credentials);
129 const selected =
130 input.permissionGroupIds === "all"
131 ? tokenCatalog.permissionGroups
132 : yield* Effect.forEach(input.permissionGroupIds, (id) => {
133 const group = tokenCatalog.permissionGroups.find(
134 (candidate) => candidate.id === id,
135 );
136 if (group === undefined) {
137 return Effect.fail(
138 new AlchemistInvalidInput({
139 field: "permissionGroupIds",
140 message: `Unknown Cloudflare permission group '${id}'.`,
141 }),
142 );
143 }
144 return Effect.succeed(group);
145 });
146 const currentUser = yield* user
147 .getUser({})
148 .pipe(withGlobalKey(input.credentials));
149 const resolved = tokenPolicies(input.accountIds, currentUser.id, selected);
150 if (resolved.length === 0) {
151 return yield* Effect.fail(
152 new AlchemistInvalidInput({
153 field: "permissionGroupIds",
154 message:
155 "No selected permission groups can be expressed as token policies.",
156 }),
157 );
158 }
159 return {
160 name: input.name,
161 accountIds: input.accountIds,
162 permissionGroupIds: selected.map(({ id }) => id),
163 permissionCount: selected.length,
164 grantsFullAccess: input.permissionGroupIds === "all",
165 policies: resolved,
166 } satisfies TokenPlan;
167});
168
169/** Mint the planned token with the user's Global API Key. */
170export const create = Effect.fn("Alchemist.cloudflare.token.create")(
171 function* (input: CreateInput) {
172 const result = yield* user.createToken({
173 name: input.plan.name,
174 policies: input.plan.policies as TokenPolicy[],
175 });
176 if (!result.value) {
177 return yield* Effect.fail(
178 new CloudflareTokenError({
179 message: "Cloudflare did not return a token value.",
180 }),
181 );
182 }
183 const granted = (result.policies ?? []).reduce(
184 (count, policy) => count + (policy.permissionGroups?.length ?? 0),
185 0,
186 );
187 const verificationStatus = yield* user.verifyToken({}).pipe(
188 Effect.provideService(
189 CloudflareCredentials.Credentials,
190 Effect.succeed(apiTokenCredentials({ apiToken: result.value })),
191 ),
192 Effect.map(({ status }) => status),
193 Effect.catch(() => Effect.succeed(undefined)),
194 );
195 return {
196 id: result.id ?? "unknown",
197 name: result.name ?? input.plan.name,
198 value: Redacted.make(result.value),
199 grantedPermissionGroups: granted,
200 policies: result.policies ?? input.plan.policies,
201 verificationStatus,
202 diagnostics:
203 granted === 0
204 ? [
205 {
206 severity: "warning" as const,
207 code: "cloudflare.token.zero-permissions",
208 message: "Cloudflare created the token with zero permissions.",
209 },
210 ]
211 : [],
212 } satisfies CreatedToken;
213 },
214 (effect, input) => withGlobalKey(input.credentials)(effect),
215);