alchemy 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +8 -3
  2. package/lib/ai/document.d.ts +12 -1
  3. package/lib/ai/document.js +10 -1
  4. package/lib/ai/index.d.ts +0 -2
  5. package/lib/ai/index.js +0 -2
  6. package/lib/alchemy.d.ts +5 -0
  7. package/lib/alchemy.js +44 -1
  8. package/lib/apply.js +17 -10
  9. package/lib/aws/account-id.d.ts +4 -1
  10. package/lib/aws/account-id.js +1 -1
  11. package/lib/aws/credentials.d.ts +5 -0
  12. package/lib/aws/credentials.js +0 -0
  13. package/lib/aws/oidc/oidc-provider.js +2 -2
  14. package/lib/aws/role.js +35 -23
  15. package/lib/cloudflare/account-api-token.d.ts +168 -0
  16. package/lib/cloudflare/account-api-token.js +179 -0
  17. package/lib/cloudflare/account-id.d.ts +1 -0
  18. package/lib/cloudflare/account-id.js +0 -0
  19. package/lib/cloudflare/api.d.ts +38 -1
  20. package/lib/cloudflare/api.js +55 -4
  21. package/lib/cloudflare/bucket.d.ts +118 -3
  22. package/lib/cloudflare/bucket.js +269 -89
  23. package/lib/cloudflare/custom-domain.d.ts +75 -0
  24. package/lib/cloudflare/custom-domain.js +154 -0
  25. package/lib/cloudflare/{dns.js → dns-records.js} +14 -1
  26. package/lib/cloudflare/index.d.ts +5 -1
  27. package/lib/cloudflare/index.js +5 -1
  28. package/lib/cloudflare/permission-groups.d.ts +78 -0
  29. package/lib/cloudflare/permission-groups.js +48 -0
  30. package/lib/cloudflare/r2-rest-state-store.d.ts +2 -1
  31. package/lib/cloudflare/r2-rest-state-store.js +3 -2
  32. package/lib/cloudflare/static-site.d.ts +17 -18
  33. package/lib/cloudflare/static-site.js +19 -10
  34. package/lib/{util/encrypt.d.ts → encrypt.d.ts} +1 -1
  35. package/lib/{util/encrypt.js → encrypt.js} +1 -1
  36. package/lib/fs/file-system-state-store.js +1 -1
  37. package/lib/fs/file.d.ts +18 -0
  38. package/lib/fs/file.js +6 -0
  39. package/lib/internal/{providers.d.ts → docs/providers.d.ts} +10 -4
  40. package/lib/internal/docs/providers.js +196 -0
  41. package/lib/secret.d.ts +3 -0
  42. package/lib/secret.js +13 -0
  43. package/lib/{util/serde.d.ts → serde.d.ts} +1 -1
  44. package/lib/{util/serde.js → serde.js} +12 -4
  45. package/lib/test/bun.js +3 -2
  46. package/lib/util/sha256.d.ts +1 -0
  47. package/lib/util/sha256.js +4 -0
  48. package/lib/web/vitepress/config.d.ts +2 -1
  49. package/lib/web/vitepress/config.js +1 -0
  50. package/lib/web/vitepress/index.d.ts +1 -0
  51. package/lib/web/vitepress/index.js +1 -0
  52. package/lib/web/vitepress/process-front-matter-files.d.ts +18 -0
  53. package/lib/web/vitepress/process-front-matter-files.js +68 -0
  54. package/lib/web/vitepress/vitepress.js +3 -2
  55. package/package.json +8 -4
  56. package/src/ai/document.ts +26 -2
  57. package/src/ai/index.ts +0 -2
  58. package/src/alchemy.ts +56 -2
  59. package/src/apply.ts +21 -20
  60. package/src/aws/account-id.ts +6 -2
  61. package/src/aws/credentials.ts +6 -0
  62. package/src/aws/oidc/oidc-provider.ts +17 -17
  63. package/src/aws/role.ts +68 -54
  64. package/src/cloudflare/account-api-token.ts +365 -0
  65. package/src/cloudflare/account-id.ts +0 -0
  66. package/src/cloudflare/api.ts +88 -17
  67. package/src/cloudflare/bucket.ts +493 -133
  68. package/src/cloudflare/custom-domain.ts +318 -0
  69. package/src/cloudflare/{dns.ts → dns-records.ts} +36 -22
  70. package/src/cloudflare/index.ts +5 -1
  71. package/src/cloudflare/permission-groups.ts +137 -0
  72. package/src/cloudflare/r2-rest-state-store.ts +14 -13
  73. package/src/cloudflare/static-site.ts +20 -32
  74. package/src/dns/import-dns.ts +4 -4
  75. package/src/{util/encrypt.ts → encrypt.ts} +7 -10
  76. package/src/fs/file-system-state-store.ts +5 -5
  77. package/src/fs/file.ts +29 -0
  78. package/src/internal/docs/providers.ts +281 -0
  79. package/src/secret.ts +16 -0
  80. package/src/{util/serde.ts → serde.ts} +16 -10
  81. package/src/test/bun.ts +8 -7
  82. package/src/util/sha256.ts +5 -0
  83. package/src/web/vitepress/config.ts +3 -1
  84. package/src/web/vitepress/index.ts +1 -0
  85. package/src/web/vitepress/process-front-matter-files.ts +98 -0
  86. package/src/web/vitepress/vitepress.ts +3 -2
  87. package/lib/ai/approve.d.ts +0 -99
  88. package/lib/ai/approve.js +0 -76
  89. package/lib/ai/review.d.ts +0 -122
  90. package/lib/ai/review.js +0 -101
  91. package/lib/internal/getting-started.d.ts +0 -21
  92. package/lib/internal/getting-started.js +0 -87
  93. package/lib/internal/index.d.ts +0 -3
  94. package/lib/internal/index.js +0 -3
  95. package/lib/internal/providers.js +0 -172
  96. package/lib/internal/tutorial.d.ts +0 -104
  97. package/lib/internal/tutorial.js +0 -251
  98. package/src/ai/approve.ts +0 -163
  99. package/src/ai/review.ts +0 -213
  100. package/src/internal/getting-started.ts +0 -115
  101. package/src/internal/index.ts +0 -3
  102. package/src/internal/providers.ts +0 -241
  103. package/src/internal/tutorial.ts +0 -392
  104. /package/lib/cloudflare/{dns.d.ts → dns-records.d.ts} +0 -0
@@ -0,0 +1,318 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ import { CloudflareApi, createCloudflareApi, handleApiError } from "./api";
4
+
5
+ /**
6
+ * Properties for creating or updating a CustomDomain
7
+ */
8
+ export interface CustomDomainProps {
9
+ /**
10
+ * The domain name to bind to the worker
11
+ */
12
+ name: string;
13
+
14
+ /**
15
+ * Cloudflare Zone ID for the domain
16
+ */
17
+ zoneId: string;
18
+
19
+ /**
20
+ * Name of the worker to bind to the domain
21
+ */
22
+ workerName: string;
23
+
24
+ /**
25
+ * Worker environment (defaults to production)
26
+ * @default "production"
27
+ */
28
+ environment?: string;
29
+ }
30
+
31
+ /**
32
+ * Cloudflare Domain object structure from API
33
+ */
34
+ interface CloudflareDomain {
35
+ id: string;
36
+ zone_id: string;
37
+ zone_name: string;
38
+ hostname: string;
39
+ service: string;
40
+ environment: string;
41
+ }
42
+
43
+ /**
44
+ * Output returned after CustomDomain creation/update
45
+ */
46
+ export interface CustomDomain
47
+ extends Resource<"cloudflare::CustomDomain">,
48
+ CustomDomainProps {
49
+ /**
50
+ * The unique identifier for the Cloudflare domain binding.
51
+ */
52
+ id: string;
53
+
54
+ /**
55
+ * Time at which the domain binding was created (approximated if not returned by API)
56
+ */
57
+ createdAt: number;
58
+
59
+ /**
60
+ * Time at which the domain binding was last updated
61
+ */
62
+ updatedAt: number;
63
+ }
64
+
65
+ /**
66
+ * Configure custom domain for a Cloudflare Worker using the Cloudflare Custom Domains API
67
+ * This attaches a worker (either a standard Worker or the one backing a StaticSite)
68
+ * to a specific hostname within a zone.
69
+ *
70
+ * @example
71
+ * // Bind a domain to a standard Cloudflare Worker
72
+ * const apiWorker = await Worker("api", {
73
+ * name: "my-api-worker",
74
+ * entrypoint: "./src/api-worker.ts"
75
+ * });
76
+ *
77
+ * const apiDomain = await CustomDomain("api-domain-binding", {
78
+ * name: "api.example.com",
79
+ * zoneId: "YOUR_ZONE_ID", // Replace with actual Zone ID
80
+ * workerName: apiWorker.name // Use the name from the Worker resource
81
+ * });
82
+ *
83
+ * @example
84
+ * // Bind a domain to a Cloudflare Static Site
85
+ * const mySite = await StaticSite("landing-page", {
86
+ * name: "my-landing-page-site", // This becomes the underlying worker name
87
+ * dir: "./dist/landing-page"
88
+ * });
89
+ *
90
+ * const siteDomain = await CustomDomain("site-domain-binding", {
91
+ * name: "www.example.com",
92
+ * zoneId: "YOUR_ZONE_ID", // Replace with actual Zone ID
93
+ * workerName: mySite.name // Use the name from the StaticSite resource
94
+ * });
95
+ *
96
+ * @see https://developers.cloudflare.com/api/resources/workers/subresources/domains/
97
+ */
98
+ export const CustomDomain = Resource(
99
+ "cloudflare::CustomDomain",
100
+ async function (
101
+ this: Context<CustomDomain>,
102
+ logicalId: string, // Changed param name from id to logicalId for clarity
103
+ props: CustomDomainProps
104
+ ): Promise<CustomDomain> {
105
+ // Create Cloudflare API client with automatic account discovery
106
+ const api = await createCloudflareApi();
107
+
108
+ // Validate required properties
109
+ if (!props.name) {
110
+ throw new Error("Domain name (props.name) is required");
111
+ }
112
+ if (!props.zoneId) {
113
+ throw new Error("Zone ID (props.zoneId) is required");
114
+ }
115
+ if (!props.workerName) {
116
+ throw new Error("Worker name (props.workerName) is required");
117
+ }
118
+
119
+ if (this.phase === "delete") {
120
+ await deleteCustomDomain(this, api, logicalId, props);
121
+ return this.destroy();
122
+ } else {
123
+ // Create or Update phase
124
+ return await ensureCustomDomain(this, api, logicalId, props);
125
+ }
126
+ }
127
+ );
128
+
129
+ // Helper function to delete the custom domain binding
130
+ async function deleteCustomDomain(
131
+ context: Context<CustomDomain>,
132
+ api: CloudflareApi,
133
+ logicalId: string,
134
+ props: CustomDomainProps
135
+ ): Promise<void> {
136
+ const domainHostname = props.name;
137
+ const domainIdToDelete = context.output?.id;
138
+
139
+ if (!domainIdToDelete) {
140
+ console.warn(
141
+ `Cannot delete CustomDomain ${logicalId} (${domainHostname}): Missing domain ID in state. Assuming already deleted.`
142
+ );
143
+ return; // Exit early if no ID
144
+ }
145
+
146
+ console.log(
147
+ `Deleting CustomDomain binding ${domainIdToDelete} for ${domainHostname}`
148
+ );
149
+ const response = await api.delete(
150
+ `/accounts/${api.accountId}/workers/domains/${domainIdToDelete}`
151
+ );
152
+
153
+ console.log(
154
+ `Delete result for ${domainIdToDelete} (${domainHostname}):`,
155
+ response.status,
156
+ response.statusText
157
+ );
158
+
159
+ // 404 is acceptable during deletion for idempotency
160
+ if (!response.ok && response.status !== 404) {
161
+ await handleApiError(
162
+ response,
163
+ "deleting",
164
+ "custom domain binding",
165
+ domainIdToDelete
166
+ );
167
+ // Throw after handling to ensure failure is reported
168
+ throw new Error(
169
+ `Failed to delete custom domain binding ${domainIdToDelete}: ${response.statusText}`
170
+ );
171
+ }
172
+ }
173
+
174
+ // Helper function to create or update the custom domain binding
175
+ async function ensureCustomDomain(
176
+ context: Context<CustomDomain>,
177
+ api: CloudflareApi,
178
+ logicalId: string,
179
+ props: CustomDomainProps
180
+ ): Promise<CustomDomain> {
181
+ const environment = props.environment || "production";
182
+ const domainHostname = props.name;
183
+
184
+ // Check if domain binding already exists for this account
185
+ console.log(`Checking existing domain bindings for account ${api.accountId}`);
186
+ const listResponse = await api.get(
187
+ `/accounts/${api.accountId}/workers/domains`
188
+ );
189
+
190
+ if (!listResponse.ok) {
191
+ // Fix: Added the 4th argument (resource identifier/context)
192
+ await handleApiError(
193
+ listResponse,
194
+ "listing",
195
+ "worker domains",
196
+ `Account ${api.accountId}`
197
+ );
198
+ // If listing fails, we cannot proceed reliably
199
+ throw new Error(
200
+ `Failed to list worker domains for account ${api.accountId}: ${listResponse.statusText}`
201
+ );
202
+ }
203
+
204
+ const listData = (await listResponse.json()) as {
205
+ result?: CloudflareDomain[];
206
+ success: boolean;
207
+ };
208
+
209
+ if (!listData.success || !listData.result) {
210
+ throw new Error(
211
+ `Failed to parse list worker domains response: ${JSON.stringify(listData)}`
212
+ );
213
+ }
214
+
215
+ // Find the specific binding by hostname AND zoneId
216
+ const existingBinding = listData.result.find(
217
+ (b) => b.hostname === domainHostname && b.zone_id === props.zoneId
218
+ );
219
+
220
+ let currentDomainId = existingBinding?.id;
221
+ const bindingExists = !!existingBinding;
222
+
223
+ console.log(
224
+ `Domain binding status for ${domainHostname} (Zone: ${props.zoneId}):`,
225
+ bindingExists
226
+ ? `Found (ID: ${currentDomainId}, Worker: ${existingBinding.service}, Env: ${existingBinding.environment})`
227
+ : "Not found"
228
+ );
229
+
230
+ // Determine if we need to update (binding exists but has different service or environment)
231
+ const needsUpdate =
232
+ bindingExists &&
233
+ (existingBinding.service !== props.workerName ||
234
+ existingBinding.environment !== environment);
235
+
236
+ let operationPerformed: "create" | "update" | "none" = "none";
237
+ let resultantBinding: CloudflareDomain | undefined = existingBinding;
238
+
239
+ // Create or Update the binding using PUT
240
+ // Cloudflare's PUT /accounts/{account_id}/workers/domains acts as an upsert
241
+ if (!bindingExists || needsUpdate) {
242
+ operationPerformed = bindingExists ? "update" : "create";
243
+ console.log(
244
+ `${operationPerformed === "update" ? "Updating" : "Creating"} domain binding: ${domainHostname} (Zone: ${props.zoneId}) → ${props.workerName}:${environment}`
245
+ );
246
+
247
+ const putPayload = {
248
+ zone_id: props.zoneId,
249
+ hostname: domainHostname,
250
+ service: props.workerName,
251
+ environment: environment,
252
+ };
253
+
254
+ const putResponse = await api.put(
255
+ `/accounts/${api.accountId}/workers/domains`,
256
+ putPayload
257
+ );
258
+
259
+ if (!putResponse.ok) {
260
+ await handleApiError(
261
+ putResponse,
262
+ operationPerformed === "update" ? "updating" : "creating",
263
+ "custom domain binding",
264
+ domainHostname
265
+ );
266
+ // Throw after handling to prevent inconsistent state
267
+ throw new Error(
268
+ `Failed to ${operationPerformed} custom domain binding: ${putResponse.statusText}`
269
+ );
270
+ }
271
+
272
+ const putResult = (await putResponse.json()) as {
273
+ result?: CloudflareDomain;
274
+ success: boolean;
275
+ };
276
+
277
+ if (!putResult.success || !putResult.result) {
278
+ throw new Error(
279
+ `Failed to parse ${operationPerformed} domain binding response: ${JSON.stringify(putResult)}`
280
+ );
281
+ }
282
+
283
+ resultantBinding = putResult.result;
284
+ currentDomainId = resultantBinding.id; // Update ID from the PUT response
285
+ console.log(
286
+ `Successfully ${operationPerformed}d binding, new ID: ${currentDomainId}`
287
+ );
288
+ } else {
289
+ console.log(
290
+ `Domain binding already exists and is up to date: ${domainHostname} (ID: ${currentDomainId}) → ${props.workerName}:${environment}`
291
+ );
292
+ }
293
+
294
+ // Ensure we have the final binding details
295
+ if (!resultantBinding || !currentDomainId) {
296
+ // This case should ideally not happen if API calls succeed
297
+ console.error("Error: Could not determine final domain binding state.", {
298
+ existingBinding,
299
+ resultantBinding,
300
+ currentDomainId,
301
+ });
302
+ throw new Error(
303
+ `Failed to get final state for custom domain ${domainHostname}`
304
+ );
305
+ }
306
+
307
+ const now = Date.now();
308
+
309
+ // Construct the output state
310
+ return context({
311
+ ...props, // Include all input props
312
+ id: currentDomainId, // Use the definitive ID
313
+ environment: resultantBinding.environment, // Use actual environment from CF
314
+ createdAt: context.output?.createdAt || now, // Preserve create time or set new
315
+ updatedAt:
316
+ operationPerformed !== "none" ? now : context.output?.updatedAt || now, // Update time only if changed
317
+ });
318
+ }
@@ -124,7 +124,7 @@ export const DnsRecords = Resource(
124
124
  async function (
125
125
  this: Context<DnsRecords>,
126
126
  id: string,
127
- props: DnsRecordsProps,
127
+ props: DnsRecordsProps
128
128
  ): Promise<DnsRecords> {
129
129
  // Create Cloudflare API client
130
130
  const api = await createCloudflareApi();
@@ -139,17 +139,17 @@ export const DnsRecords = Resource(
139
139
  this.output.records.map(async (record) => {
140
140
  try {
141
141
  const response = await api.delete(
142
- `/zones/${zoneId}/dns_records/${record.id}`,
142
+ `/zones/${zoneId}/dns_records/${record.id}`
143
143
  );
144
144
  if (!response.ok && response.status !== 404) {
145
145
  console.error(
146
- `Failed to delete DNS record ${record.name}: ${response.statusText}`,
146
+ `Failed to delete DNS record ${record.name}: ${response.statusText}`
147
147
  );
148
148
  }
149
149
  } catch (error) {
150
150
  console.error(`Error deleting DNS record ${record.name}:`, error);
151
151
  }
152
- }),
152
+ })
153
153
  );
154
154
  }
155
155
  return this.destroy();
@@ -165,8 +165,8 @@ export const DnsRecords = Resource(
165
165
  (current) =>
166
166
  !desiredRecords.some(
167
167
  (desired) =>
168
- desired.name === current.name && desired.type === current.type,
169
- ),
168
+ desired.name === current.name && desired.type === current.type
169
+ )
170
170
  );
171
171
 
172
172
  // Delete orphaned records
@@ -174,17 +174,17 @@ export const DnsRecords = Resource(
174
174
  recordsToDelete.map(async (record) => {
175
175
  try {
176
176
  const response = await api.delete(
177
- `/zones/${zoneId}/dns_records/${record.id}`,
177
+ `/zones/${zoneId}/dns_records/${record.id}`
178
178
  );
179
179
  if (!response.ok && response.status !== 404) {
180
180
  console.error(
181
- `Failed to delete DNS record ${record.name}: ${response.statusText}`,
181
+ `Failed to delete DNS record ${record.name}: ${response.statusText}`
182
182
  );
183
183
  }
184
184
  } catch (error) {
185
185
  console.error(`Error deleting DNS record ${record.name}:`, error);
186
186
  }
187
- }),
187
+ })
188
188
  );
189
189
 
190
190
  // Update or create records
@@ -193,7 +193,7 @@ export const DnsRecords = Resource(
193
193
  // Find matching existing record
194
194
  const existing = currentRecords.find(
195
195
  (current) =>
196
- current.name === desired.name && current.type === desired.type,
196
+ current.name === desired.name && current.type === desired.type
197
197
  );
198
198
 
199
199
  if (existing) {
@@ -212,7 +212,7 @@ export const DnsRecords = Resource(
212
212
  // Create new record
213
213
  return createOrUpdateRecord(api, zoneId, desired);
214
214
  }
215
- }),
215
+ })
216
216
  );
217
217
 
218
218
  return this({
@@ -224,22 +224,36 @@ export const DnsRecords = Resource(
224
224
  // Create new records
225
225
  const uniqueRecords = props.records.reduce(
226
226
  (acc, record) => {
227
- const key = `${record.name}-${record.type}`;
227
+ // For record types that can have multiple entries with the same name (MX, TXT, NS, etc.),
228
+ // include content and/or priority in the key to avoid deduplication
229
+ let key = `${record.name}-${record.type}`;
230
+
231
+ // If it's a record type that can have multiple entries with the same name, make the key unique
232
+ if (["MX", "TXT", "NS", "SRV", "CAA"].includes(record.type)) {
233
+ // For MX, include priority in the key
234
+ if (record.type === "MX" || record.type === "SRV") {
235
+ key = `${key}-${record.priority}-${record.content}`;
236
+ } else {
237
+ // For other multi-record types, content is the differentiator
238
+ key = `${key}-${record.content}`;
239
+ }
240
+ }
241
+
228
242
  acc[key] = record;
229
243
  return acc;
230
244
  },
231
- {} as Record<string, DnsRecordProps>,
245
+ {} as Record<string, DnsRecordProps>
232
246
  );
233
247
 
234
248
  const createdRecords = await Promise.all(
235
249
  Object.values(uniqueRecords).map(async (record) => {
236
250
  // First check if record exists
237
251
  const listResponse = await api.get(
238
- `/zones/${zoneId}/dns_records?type=${record.type}&name=${record.name}`,
252
+ `/zones/${zoneId}/dns_records?type=${record.type}&name=${record.name}`
239
253
  );
240
254
  if (!listResponse.ok) {
241
255
  throw new Error(
242
- `Failed to check existing DNS records: ${listResponse.statusText}`,
256
+ `Failed to check existing DNS records: ${listResponse.statusText}`
243
257
  );
244
258
  }
245
259
 
@@ -249,14 +263,14 @@ export const DnsRecords = Resource(
249
263
  const existingRecord = listResult.result[0];
250
264
 
251
265
  return createOrUpdateRecord(api, zoneId, record, existingRecord?.id);
252
- }),
266
+ })
253
267
  );
254
268
 
255
269
  return this({
256
270
  zoneId,
257
271
  records: createdRecords,
258
272
  });
259
- },
273
+ }
260
274
  );
261
275
 
262
276
  /**
@@ -266,7 +280,7 @@ async function createOrUpdateRecord(
266
280
  api: CloudflareApi,
267
281
  zoneId: string,
268
282
  record: DnsRecordProps,
269
- existingId?: string,
283
+ existingId?: string
270
284
  ): Promise<DnsRecord> {
271
285
  const payload = getRecordPayload(record);
272
286
 
@@ -282,12 +296,12 @@ async function createOrUpdateRecord(
282
296
  try {
283
297
  const createResponse = await api.post(
284
298
  `/zones/${zoneId}/dns_records`,
285
- payload,
299
+ payload
286
300
  );
287
301
  if (createResponse.ok) {
288
302
  return convertCloudflareRecord(
289
303
  ((await createResponse.json()) as any).result,
290
- zoneId,
304
+ zoneId
291
305
  );
292
306
  }
293
307
  } catch (err) {
@@ -296,7 +310,7 @@ async function createOrUpdateRecord(
296
310
  }
297
311
 
298
312
  throw new Error(
299
- `Failed to ${existingId ? "update" : "create"} DNS record ${record.name}: ${response.statusText}\nResponse: ${errorBody}`,
313
+ `Failed to ${existingId ? "update" : "create"} DNS record ${record.name}: ${response.statusText}\nResponse: ${errorBody}`
300
314
  );
301
315
  }
302
316
 
@@ -325,7 +339,7 @@ function getRecordPayload(record: DnsRecordProps) {
325
339
  */
326
340
  function convertCloudflareRecord(
327
341
  record: CloudflareDnsRecord,
328
- zoneId: string,
342
+ zoneId: string
329
343
  ): DnsRecord {
330
344
  return {
331
345
  id: record.id,
@@ -1,8 +1,12 @@
1
+ export * from "./account-api-token";
2
+ export * from "./api";
1
3
  export * from "./bindings";
2
4
  export * from "./bucket";
3
- export * from "./dns";
5
+ export * from "./custom-domain";
6
+ export * from "./dns-records";
4
7
  export * from "./durable-object-namespace";
5
8
  export * from "./kv-namespace";
9
+ export * from "./permission-groups";
6
10
  export * from "./r2-rest-state-store";
7
11
  export * from "./static-site";
8
12
  export * from "./worker";
@@ -0,0 +1,137 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ import { createCloudflareApi, type CloudflareApiOptions } from "./api";
4
+
5
+ /**
6
+ * Cloudflare permission group as returned by the API
7
+ */
8
+ export interface PermissionGroup {
9
+ /**
10
+ * Unique identifier for the permission group
11
+ */
12
+ id: string;
13
+
14
+ /**
15
+ * Human-readable name of the permission group
16
+ */
17
+ name: string;
18
+
19
+ /**
20
+ * Scopes included in this permission group
21
+ */
22
+ scopes: string[];
23
+ }
24
+
25
+ /**
26
+ * Response from the Cloudflare permission groups API
27
+ */
28
+ interface PermissionGroupsResponse {
29
+ result: PermissionGroup[];
30
+ success: boolean;
31
+ errors: any[];
32
+ messages: any[];
33
+ }
34
+
35
+ /**
36
+ * All Cloudflare permission groups mapped by name to ID
37
+ *
38
+ * @see https://developers.cloudflare.com/r2/api/tokens/#permissions
39
+ */
40
+ export type PermissionGroups = Resource<"cloudflare::PermissionGroups"> & {
41
+ /**
42
+ * Admin Read & Write - Allows create, list, delete buckets and edit bucket configurations
43
+ * plus list, write, and read object access
44
+ */
45
+ "Workers R2 Storage Write": PermissionGroup;
46
+
47
+ /**
48
+ * Admin Read only - Allows list buckets and view bucket configuration
49
+ * plus list and read object access
50
+ */
51
+ "Workers R2 Storage Read": PermissionGroup;
52
+
53
+ /**
54
+ * Object Read & Write - Allows read, write, and list objects in specific buckets
55
+ */
56
+ "Workers R2 Storage Bucket Item Write": PermissionGroup;
57
+
58
+ /**
59
+ * Object Read only - Allows read and list objects in specific buckets
60
+ */
61
+ "Workers R2 Storage Bucket Item Read": PermissionGroup;
62
+
63
+ /**
64
+ * Dynamically discovered permission groups
65
+ */
66
+ [name: string]: PermissionGroup;
67
+ };
68
+
69
+ /**
70
+ * Lists all permission groups available for the Cloudflare account
71
+ * and returns a typed map of permission names to their IDs.
72
+ *
73
+ * This is primarily used when creating API tokens for Cloudflare services like R2.
74
+ *
75
+ * @example
76
+ * // Get all permission groups including those for R2
77
+ * const permissions = await PermissionGroups("cloudflare-permissions");
78
+ *
79
+ * // Use with AccountApiToken to create a token with proper permissions
80
+ * const token = await AccountApiToken("r2-token", {
81
+ * name: "R2 Read-Only Token",
82
+ * policies: [
83
+ * {
84
+ * effect: "allow",
85
+ * resources: {
86
+ * "com.cloudflare.edge.r2.bucket.abc123_default_my-bucket": "*"
87
+ * },
88
+ * permissionGroups: [
89
+ * {
90
+ * id: permissions["Workers R2 Storage Bucket Item Read"]
91
+ * }
92
+ * ]
93
+ * }
94
+ * ]
95
+ * });
96
+ */
97
+ export const PermissionGroups = Resource(
98
+ "cloudflare::PermissionGroups",
99
+ async function (
100
+ this: Context<PermissionGroups>,
101
+ id: string,
102
+ options?: CloudflareApiOptions
103
+ ): Promise<PermissionGroups> {
104
+ // Only create and update phases are supported
105
+ if (this.phase === "delete") {
106
+ return this.destroy();
107
+ }
108
+
109
+ // Initialize API client
110
+ const api = await createCloudflareApi(options);
111
+
112
+ // Fetch permission groups from Cloudflare API
113
+ const response = await api.get(
114
+ `/accounts/${api.accountId}/tokens/permission_groups`
115
+ );
116
+
117
+ if (!response.ok) {
118
+ throw new Error(
119
+ `Failed to fetch permission groups: ${response.statusText}`
120
+ );
121
+ }
122
+
123
+ const data = (await response.json()) as PermissionGroupsResponse;
124
+
125
+ if (!data.success || !data.result) {
126
+ throw new Error(
127
+ `API returned error: ${data.errors?.[0]?.message || "Unknown error"}`
128
+ );
129
+ }
130
+
131
+ return this(
132
+ Object.fromEntries(
133
+ data.result.map((group) => [group.name, group])
134
+ ) as PermissionGroups
135
+ );
136
+ }
137
+ );