1import {
2 PERMISSION_GROUPS_BY_NAME,
3 type PermissionGroupName,
4} from "./PermissionGroups.ts";
5
6/**
7 * Resource keys recognized by Cloudflare API token policies.
8 *
9 * Cloudflare requires the account ID to be embedded directly in the resource
10 * key (e.g. `com.cloudflare.api.account.<accountId>`); pass the fully-qualified
11 * key — no rewriting is performed.
12 *
13 * @see https://developers.cloudflare.com/fundamentals/api/reference/permissions/
14 */
15export type ResourceKey =
16 | `com.cloudflare.api.account.${string}`
17 | `com.cloudflare.api.account.zone.${string}`
18 | `com.cloudflare.edge.r2.bucket.${string}`
19 | (string & {});
20
21/**
22 * A permission group reference: either a typed Cloudflare permission-group
23 * name (resolved against the static catalog) or an explicit `{ id }` for
24 * names that aren't in the catalog or have multiple scopes.
25 */
26export type PermissionGroupRef =
27 | PermissionGroupName
28 | { id: string; meta?: { key?: string; value?: string } };
29
30/**
31 * Value of a resource entry in an {@link Policy}. Usually `"*"`, but
32 * account-owned tokens must nest zone resources under the account resource —
33 * e.g. `{ "com.cloudflare.api.account.<id>": { "com.cloudflare.api.account.zone.*": "*" } }`
34 * — so a nested object is also allowed.
35 */
36export type ResourceScope = string | { [K in ResourceKey]?: string };
37
38export interface Policy {
39 effect: "allow" | "deny";
40 permissionGroups: PermissionGroupRef[];
41 resources: { [K in ResourceKey]?: ResourceScope };
42}
43
44export interface Condition {
45 requestIp?: {
46 in?: string[];
47 notIn?: string[];
48 };
49}
50
51export type Props = {
52 /**
53 * Token name. Defaults to a generated physical name based on the
54 * resource's logical id, app name, and stage.
55 */
56 name?: string;
57 /**
58 * The Cloudflare account ID that owns this token. Defaults to the
59 * account ID resolved from the ambient {@link CloudflareEnvironment}.
60 */
61 accountId?: string;
62 /**
63 * Access policies attached to the token. Cloudflare requires at least one
64 * policy on a token; if you omit `policies` here, the policies must instead
65 * be contributed by bindings (see {@link ApiTokenBinding}).
66 */
67 policies?: Policy[];
68 /** ISO 8601 expiration timestamp. */
69 expiresOn?: string;
70 /** ISO 8601 "not before" timestamp. */
71 notBefore?: string;
72 /** Optional usage conditions (e.g. IP allowlist). */
73 condition?: Condition;
74};
75
76/**
77 * Binding contract for {@link AccountApiToken} / {@link UserApiToken}.
78 *
79 * A binding contributes additional access policies to the token. This lets a
80 * downstream resource (e.g. a runtime capability that needs to call a specific
81 * Cloudflare API) create a token and attach exactly the policies it requires,
82 * without the token's owner having to enumerate them up front.
83 *
84 * Binding-contributed policies are merged with any `policies` passed directly
85 * as props; the union must contain at least one policy.
86 */
87export type ApiTokenBinding = {
88 /** Access policies to attach to the token. */
89 policies?: Policy[];
90};
91
92/**
93 * Collect the policies a token should be created with: those passed directly
94 * as props, plus those contributed by bindings.
95 */
96export const collectPolicies = (
97 props: Policy[] | undefined,
98 bindings: { data: ApiTokenBinding }[],
99): Policy[] => [
100 ...(props ?? []),
101 ...bindings.flatMap((binding) => binding.data.policies ?? []),
102];
103
104export type ResolvedPolicy = {
105 effect: "allow" | "deny";
106 permissionGroups: { id: string; meta?: { key?: string; value?: string } }[];
107 resources: Record<string, ResourceScope>;
108};
109
110export const resolvePermissionGroup = (ref: PermissionGroupRef) => {
111 if (typeof ref === "string") {
112 const group = PERMISSION_GROUPS_BY_NAME[ref];
113 if (!group) {
114 // Should be unreachable due to the typed union, but guard anyway in
115 // case Cloudflare retires a name we still have in the catalog.
116 throw new Error(
117 `Unknown Cloudflare permission group: "${ref}". Pass an explicit { id } instead.`,
118 );
119 }
120 return { id: group.id };
121 }
122 return ref.meta ? { id: ref.id, meta: ref.meta } : { id: ref.id };
123};
124
125const resolveResources = (
126 resources: Policy["resources"],
127): Record<string, ResourceScope> => {
128 const out: Record<string, ResourceScope> = {};
129 for (const [key, value] of Object.entries(resources)) {
130 if (value === undefined) continue;
131 out[key] = value;
132 }
133 return out;
134};
135
136export const resolvePolicies = (policies: Policy[]): ResolvedPolicy[] =>
137 policies.map((policy) => ({
138 effect: policy.effect,
139 permissionGroups: policy.permissionGroups.map(resolvePermissionGroup),
140 resources: resolveResources(policy.resources),
141 }));
142
143export const policyFingerprint = (policies: ResolvedPolicy[]): string =>
144 JSON.stringify(
145 policies.map((p) => ({
146 effect: p.effect,
147 permissionGroups: [...p.permissionGroups]
148 .map((g) => ({ id: g.id, meta: g.meta ?? null }))
149 .sort((a, b) => a.id.localeCompare(b.id)),
150 resources: Object.keys(p.resources)
151 .sort()
152 .map((k) => [k, p.resources[k]]),
153 })),
154 );
155
156export const conditionFingerprint = (
157 condition: Condition | undefined,
158): string =>
159 JSON.stringify({
160 in: [...(condition?.requestIp?.in ?? [])].sort(),
161 notIn: [...(condition?.requestIp?.notIn ?? [])].sort(),
162 });
163
164export const buildConditionPayload = (condition: Condition | undefined) =>
165 condition
166 ? {
167 requestIp: condition.requestIp
168 ? {
169 in: condition.requestIp.in,
170 notIn: condition.requestIp.notIn,
171 }
172 : undefined,
173 }
174 : undefined;