alchemy 0.5.2 → 0.6.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.
- package/lib/cloudflare/account-api-token.d.ts +6 -1
- package/lib/cloudflare/account-api-token.js +5 -1
- package/lib/cloudflare/account-id.d.ts +5 -1
- package/lib/cloudflare/account-id.js +5 -0
- package/lib/cloudflare/api-error.d.ts +32 -0
- package/lib/cloudflare/api-error.js +47 -0
- package/lib/cloudflare/api.d.ts +16 -49
- package/lib/cloudflare/api.js +37 -149
- package/lib/cloudflare/auth.d.ts +55 -38
- package/lib/cloudflare/auth.js +148 -77
- package/lib/cloudflare/bucket.d.ts +30 -5
- package/lib/cloudflare/bucket.js +12 -11
- package/lib/cloudflare/custom-domain.d.ts +2 -1
- package/lib/cloudflare/custom-domain.js +3 -2
- package/lib/cloudflare/dns-records.d.ts +2 -1
- package/lib/cloudflare/dns-records.js +2 -2
- package/lib/cloudflare/kv-namespace.d.ts +7 -1
- package/lib/cloudflare/kv-namespace.js +72 -71
- package/lib/cloudflare/permission-groups.d.ts +4 -0
- package/lib/cloudflare/permission-groups.js +5 -1
- package/lib/cloudflare/r2-rest-state-store.d.ts +3 -17
- package/lib/cloudflare/r2-rest-state-store.js +4 -12
- package/lib/cloudflare/worker.d.ts +2 -6
- package/lib/cloudflare/worker.js +2 -63
- package/lib/cloudflare/zone.d.ts +2 -6
- package/lib/cloudflare/zone.js +1 -3
- package/lib/internal/docs/providers.js +0 -7
- package/package.json +5 -1
- package/src/cloudflare/account-api-token.ts +7 -3
- package/src/cloudflare/account-id.ts +12 -0
- package/src/cloudflare/api-error.ts +58 -0
- package/src/cloudflare/api.ts +50 -216
- package/src/cloudflare/auth.ts +262 -119
- package/src/cloudflare/bucket.ts +52 -29
- package/src/cloudflare/custom-domain.ts +8 -3
- package/src/cloudflare/dns-records.ts +7 -3
- package/src/cloudflare/kv-namespace.ts +117 -105
- package/src/cloudflare/permission-groups.ts +5 -1
- package/src/cloudflare/r2-rest-state-store.ts +8 -31
- package/src/cloudflare/worker.ts +8 -105
- package/src/cloudflare/zone.ts +17 -25
- package/src/internal/docs/providers.ts +0 -7
package/lib/cloudflare/auth.js
CHANGED
|
@@ -1,79 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { handleApiError } from "./api-error";
|
|
5
|
+
const userInfoCache = {};
|
|
6
|
+
export async function getCloudflareUserInfo(options) {
|
|
7
|
+
const cacheKey = JSON.stringify({
|
|
8
|
+
apiKey: options.apiKey?.unencrypted,
|
|
9
|
+
apiToken: options.apiToken?.unencrypted,
|
|
10
|
+
email: options.email,
|
|
11
|
+
});
|
|
12
|
+
if (userInfoCache[cacheKey]) {
|
|
13
|
+
return userInfoCache[cacheKey];
|
|
14
|
+
}
|
|
15
|
+
const headers = await getCloudflareAuthHeaders(options);
|
|
16
|
+
const user = await fetch("https://api.cloudflare.com/client/v4/user", {
|
|
17
|
+
headers,
|
|
18
|
+
});
|
|
19
|
+
if (!user.ok) {
|
|
20
|
+
if (user.status === 403) {
|
|
21
|
+
throw new Error("Cloudflare authentication required. Did you forget to login with `wrangler login` or set CLOUDFLARE_API_TOKEN, CLOUDFLARE_API_KEY, or CLOUDFLARE_EMAIL, or CLOUDFLARE_API_KEY?");
|
|
22
|
+
}
|
|
23
|
+
await handleApiError(user, "getting", "user", "user");
|
|
24
|
+
}
|
|
25
|
+
const accounts = await fetch("https://api.cloudflare.com/client/v4/accounts", {
|
|
26
|
+
headers,
|
|
27
|
+
});
|
|
28
|
+
const userInfo = {
|
|
29
|
+
...(await user.json()).result,
|
|
30
|
+
accounts: (await accounts.json()).result,
|
|
31
|
+
};
|
|
32
|
+
userInfoCache[cacheKey] = userInfo;
|
|
33
|
+
return userInfo;
|
|
34
|
+
}
|
|
35
|
+
export async function getCloudflareAuthHeaders(options = {}) {
|
|
13
36
|
// Check for API Token (preferred method)
|
|
14
|
-
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
37
|
+
const apiToken = options.apiToken?.unencrypted ?? process.env.CLOUDFLARE_API_TOKEN;
|
|
15
38
|
if (apiToken) {
|
|
16
39
|
return {
|
|
17
|
-
|
|
18
|
-
Authorization: `Bearer ${apiToken}`,
|
|
19
|
-
},
|
|
20
|
-
type: "token",
|
|
40
|
+
Authorization: `Bearer ${apiToken}`,
|
|
21
41
|
};
|
|
22
42
|
}
|
|
23
43
|
// Check for API Key
|
|
24
|
-
const apiKey = process.env.CLOUDFLARE_API_KEY;
|
|
44
|
+
const apiKey = options.apiKey?.unencrypted ?? process.env.CLOUDFLARE_API_KEY;
|
|
25
45
|
if (apiKey) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"X-Auth-Email": providedEmail,
|
|
33
|
-
},
|
|
34
|
-
type: "key",
|
|
35
|
-
email: providedEmail,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
// Otherwise, try to discover email from Cloudflare API
|
|
39
|
-
try {
|
|
40
|
-
const email = await getUserEmailFromApiKey(apiKey);
|
|
41
|
-
return {
|
|
42
|
-
headers: {
|
|
43
|
-
"X-Auth-Key": apiKey,
|
|
44
|
-
"X-Auth-Email": email,
|
|
45
|
-
},
|
|
46
|
-
type: "key",
|
|
47
|
-
email,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
console.error("Failed to discover email from API key:", error);
|
|
52
|
-
throw new Error("Failed to automatically discover email for API Key authentication.\n" +
|
|
53
|
-
"Options:\n" +
|
|
54
|
-
"1. Set CLOUDFLARE_EMAIL environment variable\n" +
|
|
55
|
-
"2. Use CLOUDFLARE_API_TOKEN instead (recommended by Cloudflare)");
|
|
56
|
-
}
|
|
46
|
+
return {
|
|
47
|
+
"X-Auth-Key": apiKey,
|
|
48
|
+
"X-Auth-Email": options.email ??
|
|
49
|
+
process.env.CLOUDFLARE_EMAIL ??
|
|
50
|
+
(await getUserEmailFromApiKey(apiKey)),
|
|
51
|
+
};
|
|
57
52
|
}
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"2. CLOUDFLARE_API_KEY environment variable");
|
|
53
|
+
const authConfig = await getRefreshedAuthConfig();
|
|
54
|
+
if (authConfig.oauth_token) {
|
|
55
|
+
return {
|
|
56
|
+
Authorization: `Bearer ${authConfig.oauth_token}`,
|
|
57
|
+
};
|
|
64
58
|
}
|
|
65
|
-
|
|
66
|
-
headers: {},
|
|
67
|
-
type: "none",
|
|
68
|
-
};
|
|
59
|
+
throw new Error("Cloudflare authentication required. Did you forget to login with `wrangler login` or set CLOUDFLARE_API_TOKEN, CLOUDFLARE_API_KEY, or CLOUDFLARE_EMAIL, or CLOUDFLARE_API_KEY?");
|
|
69
60
|
}
|
|
70
|
-
|
|
71
|
-
* Fetch the user's email from Cloudflare API using API Key
|
|
72
|
-
*
|
|
73
|
-
* @param apiKey The Cloudflare API key
|
|
74
|
-
* @returns The user's email
|
|
75
|
-
*/
|
|
61
|
+
const emailCache = {};
|
|
76
62
|
async function getUserEmailFromApiKey(apiKey) {
|
|
63
|
+
if (emailCache[apiKey]) {
|
|
64
|
+
return emailCache[apiKey];
|
|
65
|
+
}
|
|
77
66
|
try {
|
|
78
67
|
const baseUrl = "https://api.cloudflare.com/client/v4";
|
|
79
68
|
// Call the /user endpoint to get user information
|
|
@@ -90,6 +79,7 @@ async function getUserEmailFromApiKey(apiKey) {
|
|
|
90
79
|
if (!data.success || !data.result || !data.result.email) {
|
|
91
80
|
throw new Error("Cloudflare API did not return valid user information");
|
|
92
81
|
}
|
|
82
|
+
emailCache[apiKey] = data.result.email;
|
|
93
83
|
return data.result.email;
|
|
94
84
|
}
|
|
95
85
|
catch (error) {
|
|
@@ -97,17 +87,98 @@ async function getUserEmailFromApiKey(apiKey) {
|
|
|
97
87
|
throw new Error("Failed to automatically discover email for API Key authentication");
|
|
98
88
|
}
|
|
99
89
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
90
|
+
async function refreshAuthToken(options) {
|
|
91
|
+
const response = await fetch("https://dash.cloudflare.com/oauth2/token", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
95
|
+
},
|
|
96
|
+
body: new URLSearchParams({
|
|
97
|
+
grant_type: "refresh_token",
|
|
98
|
+
refresh_token: options.refresh_token,
|
|
99
|
+
client_id: "54d11594-84e4-41aa-b438-e81b8fa78ee7",
|
|
100
|
+
}).toString(),
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
throw new Error(`Failed to refresh auth token: ${response.status} ${response.statusText}`);
|
|
104
|
+
}
|
|
105
|
+
const data = await response.json();
|
|
106
|
+
if (!data.access_token) {
|
|
107
|
+
throw new Error("Failed to refresh auth token - no access token returned");
|
|
108
|
+
}
|
|
109
|
+
options.oauth_token = data.access_token;
|
|
110
|
+
options.refresh_token = data.refresh_token;
|
|
111
|
+
options.expiration_time = new Date(Date.now() + data.expires_in * 1000).toISOString();
|
|
112
|
+
options.scopes = data.scope?.split(" ") || [];
|
|
113
|
+
return options;
|
|
114
|
+
}
|
|
115
|
+
async function getRefreshedAuthConfig() {
|
|
116
|
+
let authConfig = await readWranglerConfig();
|
|
117
|
+
if (authConfig.expiration_time) {
|
|
118
|
+
const expiry = new Date(authConfig.expiration_time);
|
|
119
|
+
// if expiring in 10s
|
|
120
|
+
if (expiry.getTime() < Date.now() + 10 * 1000) {
|
|
121
|
+
authConfig = await refreshAuthToken(authConfig);
|
|
122
|
+
authConfigCache[authConfig.path] = authConfig;
|
|
123
|
+
await writeWranglerConfig(authConfig);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return authConfig;
|
|
127
|
+
}
|
|
128
|
+
async function writeWranglerConfig(config) {
|
|
129
|
+
const TOML = await import("@iarna/toml");
|
|
130
|
+
const configPath = await findWranglerConfig();
|
|
131
|
+
config = {
|
|
132
|
+
...config,
|
|
112
133
|
};
|
|
134
|
+
// @ts-ignore - i put this here
|
|
135
|
+
delete config.path;
|
|
136
|
+
const toml = TOML.stringify(config);
|
|
137
|
+
await fs.writeFile(configPath, toml);
|
|
138
|
+
}
|
|
139
|
+
// cache the file once per process
|
|
140
|
+
const authConfigCache = {};
|
|
141
|
+
async function readWranglerConfig() {
|
|
142
|
+
const configPath = await findWranglerConfig();
|
|
143
|
+
const config = (authConfigCache[configPath] ??= await parseTOML(await fs.readFile(configPath, "utf-8")));
|
|
144
|
+
config.path = configPath;
|
|
145
|
+
return config;
|
|
146
|
+
}
|
|
147
|
+
let wranglerConfigPath;
|
|
148
|
+
async function findWranglerConfig() {
|
|
149
|
+
if (wranglerConfigPath) {
|
|
150
|
+
return wranglerConfigPath;
|
|
151
|
+
}
|
|
152
|
+
const environment = process.env.WRANGLER_API_ENVIRONMENT ?? "production";
|
|
153
|
+
const filePath = path.join("config", `${environment === "production" ? "default.toml" : `${environment}.toml`}`);
|
|
154
|
+
const xdgAppPaths = (await import("xdg-app-paths")).default;
|
|
155
|
+
//TODO: We should implement a custom path --global-config and/or the WRANGLER_HOME type environment variable
|
|
156
|
+
const configDir = xdgAppPaths(".wrangler").config(); // New XDG compliant config path
|
|
157
|
+
const legacyConfigDir = path.join(os.homedir(), ".wrangler"); // Legacy config in user's home directory
|
|
158
|
+
// Check for the .wrangler directory in root if it is not there then use the XDG compliant path.
|
|
159
|
+
wranglerConfigPath = path.join((await isDirectory(legacyConfigDir)) ? legacyConfigDir : configDir, filePath);
|
|
160
|
+
return wranglerConfigPath;
|
|
161
|
+
}
|
|
162
|
+
async function parseTOML(input) {
|
|
163
|
+
const TOML = await import("@iarna/toml");
|
|
164
|
+
try {
|
|
165
|
+
// Normalize CRLF to LF to avoid hitting https://github.com/iarna/iarna-toml/issues/33.
|
|
166
|
+
const normalizedInput = input.replace(/\r\n/g, "\n");
|
|
167
|
+
return TOML.parse(normalizedInput);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const { name } = err;
|
|
171
|
+
if (name !== "TomlError") {
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
throw new Error("TOML parse error");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function isDirectory(dir) {
|
|
178
|
+
try {
|
|
179
|
+
return (await fs.stat(dir)).isDirectory();
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
113
184
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { AwsClient } from "aws4fetch";
|
|
2
2
|
import type { Context } from "../context";
|
|
3
3
|
import { Resource } from "../resource";
|
|
4
|
-
import {
|
|
4
|
+
import type { Secret } from "../secret";
|
|
5
|
+
import { CloudflareApi } from "./api";
|
|
5
6
|
/**
|
|
6
7
|
* Properties for creating or updating an R2 Bucket
|
|
7
8
|
*/
|
|
8
|
-
export interface BucketProps
|
|
9
|
+
export interface BucketProps {
|
|
9
10
|
/**
|
|
10
11
|
* Name of the bucket
|
|
11
12
|
* Names can only contain lowercase letters (a-z), numbers (0-9), and hyphens (-)
|
|
@@ -39,6 +40,30 @@ export interface BucketProps extends CloudflareApiOptions {
|
|
|
39
40
|
* @default false
|
|
40
41
|
*/
|
|
41
42
|
empty?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* API Token to use for the bucket
|
|
45
|
+
*/
|
|
46
|
+
apiToken?: Secret;
|
|
47
|
+
/**
|
|
48
|
+
* API Key to use for the bucket
|
|
49
|
+
*/
|
|
50
|
+
apiKey?: Secret;
|
|
51
|
+
/**
|
|
52
|
+
* Email to use for the bucket
|
|
53
|
+
*/
|
|
54
|
+
email?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Account ID to use for the bucket
|
|
57
|
+
*/
|
|
58
|
+
accountId?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Access Key to use for the bucket
|
|
61
|
+
*/
|
|
62
|
+
accessKey?: Secret;
|
|
63
|
+
/**
|
|
64
|
+
* Secret Access Key to use for the bucket
|
|
65
|
+
*/
|
|
66
|
+
secretAccessKey?: Secret;
|
|
42
67
|
}
|
|
43
68
|
/**
|
|
44
69
|
* Output returned after R2 Bucket creation/update
|
|
@@ -106,9 +131,9 @@ export declare const R2Bucket: (((this: any, id: string, props?: {}) => never) &
|
|
|
106
131
|
* Configuration for R2 client to connect to Cloudflare R2
|
|
107
132
|
*/
|
|
108
133
|
export interface R2ClientConfig {
|
|
109
|
-
accountId
|
|
110
|
-
accessKeyId?:
|
|
111
|
-
secretAccessKey?:
|
|
134
|
+
accountId: string;
|
|
135
|
+
accessKeyId?: Secret;
|
|
136
|
+
secretAccessKey?: Secret;
|
|
112
137
|
jurisdiction?: string;
|
|
113
138
|
}
|
|
114
139
|
type R2Client = AwsClient & {
|
package/lib/cloudflare/bucket.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AwsClient } from "aws4fetch";
|
|
2
2
|
import { Resource } from "../resource";
|
|
3
|
-
import {
|
|
3
|
+
import { CloudflareApi, createCloudflareApi } from "./api";
|
|
4
|
+
import { CloudflareApiError, handleApiError } from "./api-error";
|
|
4
5
|
/**
|
|
5
6
|
* Creates and manages Cloudflare R2 Buckets for object storage.
|
|
6
7
|
*
|
|
@@ -53,7 +54,10 @@ export const R2Bucket = Resource("cloudflare::R2Bucket", async function (id, pro
|
|
|
53
54
|
if (props.delete !== false) {
|
|
54
55
|
if (props.empty) {
|
|
55
56
|
console.log("Emptying R2 bucket:", bucketName);
|
|
56
|
-
const r2Client = await createR2Client(
|
|
57
|
+
const r2Client = await createR2Client({
|
|
58
|
+
...props,
|
|
59
|
+
accountId: api.accountId,
|
|
60
|
+
});
|
|
57
61
|
// Empty the bucket first by deleting all objects
|
|
58
62
|
await emptyBucket(r2Client, bucketName, props.jurisdiction);
|
|
59
63
|
}
|
|
@@ -76,6 +80,7 @@ export const R2Bucket = Resource("cloudflare::R2Bucket", async function (id, pro
|
|
|
76
80
|
creationDate: new Date(),
|
|
77
81
|
jurisdiction: props.jurisdiction || "default",
|
|
78
82
|
type: "r2_bucket",
|
|
83
|
+
accountId: api.accountId,
|
|
79
84
|
});
|
|
80
85
|
}
|
|
81
86
|
});
|
|
@@ -84,10 +89,10 @@ export const R2Bucket = Resource("cloudflare::R2Bucket", async function (id, pro
|
|
|
84
89
|
*
|
|
85
90
|
* @see https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js-v3/
|
|
86
91
|
*/
|
|
87
|
-
export
|
|
88
|
-
const accountId =
|
|
89
|
-
const accessKeyId = config
|
|
90
|
-
const secretAccessKey = config
|
|
92
|
+
export function createR2Client(config) {
|
|
93
|
+
const accountId = config?.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
94
|
+
const accessKeyId = config?.accessKeyId?.unencrypted || process.env.R2_ACCESS_KEY_ID;
|
|
95
|
+
const secretAccessKey = config?.secretAccessKey?.unencrypted || process.env.R2_SECRET_ACCESS_KEY;
|
|
91
96
|
if (!accountId) {
|
|
92
97
|
throw new Error("CLOUDFLARE_ACCOUNT_ID environment variable is required");
|
|
93
98
|
}
|
|
@@ -140,10 +145,7 @@ export async function createBucket(api, bucketName, props) {
|
|
|
140
145
|
const headers = withJurisdiction({}, props);
|
|
141
146
|
const createResponse = await api.post(`/accounts/${api.accountId}/r2/buckets`, createPayload, { headers });
|
|
142
147
|
if (!createResponse.ok) {
|
|
143
|
-
|
|
144
|
-
errors: [{ message: createResponse.statusText }],
|
|
145
|
-
}));
|
|
146
|
-
throw new CloudflareApiError(`Error creating R2 bucket '${bucketName}': ${errorData.errors?.[0]?.message || createResponse.statusText}`, createResponse);
|
|
148
|
+
return await handleApiError(createResponse, "creating", "R2 bucket", bucketName);
|
|
147
149
|
}
|
|
148
150
|
return (await createResponse.json());
|
|
149
151
|
}
|
|
@@ -251,7 +253,6 @@ export async function emptyBucket(r2, bucketName, jurisdiction) {
|
|
|
251
253
|
* so we still use the Cloudflare API directly.
|
|
252
254
|
*/
|
|
253
255
|
export async function updatePublicAccess(api, bucketName, allowPublicAccess, jurisdiction) {
|
|
254
|
-
console.log(`/accounts/${api.accountId}/r2/buckets/${bucketName}/domains/managed`);
|
|
255
256
|
const headers = withJurisdiction({}, jurisdiction);
|
|
256
257
|
const response = await api.put(`/accounts/${api.accountId}/r2/buckets/${bucketName}/domains/managed`, {
|
|
257
258
|
enabled: allowPublicAccess,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Context } from "../context";
|
|
2
2
|
import { Resource } from "../resource";
|
|
3
|
+
import { type CloudflareApiOptions } from "./api";
|
|
3
4
|
/**
|
|
4
5
|
* Properties for creating or updating a CustomDomain
|
|
5
6
|
*/
|
|
6
|
-
export interface CustomDomainProps {
|
|
7
|
+
export interface CustomDomainProps extends CloudflareApiOptions {
|
|
7
8
|
/**
|
|
8
9
|
* The domain name to bind to the worker
|
|
9
10
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Resource } from "../resource";
|
|
2
|
-
import { CloudflareApi, createCloudflareApi,
|
|
2
|
+
import { CloudflareApi, createCloudflareApi, } from "./api";
|
|
3
|
+
import { handleApiError } from "./api-error";
|
|
3
4
|
/**
|
|
4
5
|
* Configure custom domain for a Cloudflare Worker using the Cloudflare Custom Domains API
|
|
5
6
|
* This attaches a worker to a specific hostname within a zone.
|
|
@@ -22,7 +23,7 @@ import { CloudflareApi, createCloudflareApi, handleApiError } from "./api";
|
|
|
22
23
|
export const CustomDomain = Resource("cloudflare::CustomDomain", async function (logicalId, // Changed param name from id to logicalId for clarity
|
|
23
24
|
props) {
|
|
24
25
|
// Create Cloudflare API client with automatic account discovery
|
|
25
|
-
const api = await createCloudflareApi();
|
|
26
|
+
const api = await createCloudflareApi(props);
|
|
26
27
|
// Validate required properties
|
|
27
28
|
if (!props.name) {
|
|
28
29
|
throw new Error("Domain name (props.name) is required");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context } from "../context";
|
|
2
2
|
import type { DnsRecord as BaseDnsRecord, DnsRecordType, DnsRecordWithMetadata } from "../dns/record";
|
|
3
3
|
import { Resource } from "../resource";
|
|
4
|
+
import { type CloudflareApiOptions } from "./api";
|
|
4
5
|
/**
|
|
5
6
|
* Properties for a DNS record
|
|
6
7
|
*/
|
|
@@ -18,7 +19,7 @@ export interface DnsRecord extends DnsRecordWithMetadata {
|
|
|
18
19
|
/**
|
|
19
20
|
* Properties for managing multiple DNS records
|
|
20
21
|
*/
|
|
21
|
-
export interface DnsRecordsProps {
|
|
22
|
+
export interface DnsRecordsProps extends CloudflareApiOptions {
|
|
22
23
|
/**
|
|
23
24
|
* Zone ID or domain name where records will be created
|
|
24
25
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Resource } from "../resource";
|
|
2
|
-
import { createCloudflareApi } from "./api";
|
|
2
|
+
import { createCloudflareApi, } from "./api";
|
|
3
3
|
/**
|
|
4
4
|
* Manages a batch of DNS records in a Cloudflare zone.
|
|
5
5
|
* Supports creating, updating, and deleting multiple records at once.
|
|
@@ -46,7 +46,7 @@ import { createCloudflareApi } from "./api";
|
|
|
46
46
|
*/
|
|
47
47
|
export const DnsRecords = Resource("cloudflare::DnsRecords", async function (id, props) {
|
|
48
48
|
// Create Cloudflare API client
|
|
49
|
-
const api = await createCloudflareApi();
|
|
49
|
+
const api = await createCloudflareApi(props);
|
|
50
50
|
// Get zone ID if domain name was provided
|
|
51
51
|
const zoneId = props.zoneId;
|
|
52
52
|
if (this.phase === "delete") {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { Context } from "../context";
|
|
2
2
|
import { Resource } from "../resource";
|
|
3
|
+
import { CloudflareApi, type CloudflareApiOptions } from "./api";
|
|
3
4
|
export declare function isKVNamespace(resource: any): resource is KVNamespace;
|
|
4
5
|
/**
|
|
5
6
|
* Properties for creating or updating a KV Namespace
|
|
6
7
|
*/
|
|
7
|
-
export interface KVNamespaceProps {
|
|
8
|
+
export interface KVNamespaceProps extends CloudflareApiOptions {
|
|
8
9
|
/**
|
|
9
10
|
* Title of the namespace
|
|
10
11
|
*/
|
|
@@ -95,3 +96,8 @@ export interface KVNamespace extends Resource<"cloudflare::KVNamespace">, KVName
|
|
|
95
96
|
* });
|
|
96
97
|
*/
|
|
97
98
|
export declare const KVNamespace: (((this: any, id: string, props?: {}) => never) & (new (_: never) => never)) | ((this: Context<KVNamespace>, id: string, props: KVNamespaceProps) => Promise<KVNamespace>);
|
|
99
|
+
export declare function createKVNamespace(api: CloudflareApi, props: KVNamespaceProps): Promise<{
|
|
100
|
+
id: string;
|
|
101
|
+
}>;
|
|
102
|
+
export declare function deleteKVNamespace(api: CloudflareApi, namespaceId: string): Promise<void>;
|
|
103
|
+
export declare function insertKVRecords(api: CloudflareApi, namespaceId: string, props: KVNamespaceProps): Promise<void>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Resource } from "../resource";
|
|
2
2
|
import { withExponentialBackoff } from "../util/retry";
|
|
3
|
-
import { createCloudflareApi } from "./api";
|
|
3
|
+
import { CloudflareApi, createCloudflareApi, } from "./api";
|
|
4
|
+
import { handleApiError } from "./api-error";
|
|
4
5
|
export function isKVNamespace(resource) {
|
|
5
6
|
return (resource && typeof resource === "object" && resource.type === "kv_namespace");
|
|
6
7
|
}
|
|
@@ -42,19 +43,12 @@ export function isKVNamespace(resource) {
|
|
|
42
43
|
*/
|
|
43
44
|
export const KVNamespace = Resource("cloudflare::KVNamespace", async function (id, props) {
|
|
44
45
|
// Create Cloudflare API client with automatic account discovery
|
|
45
|
-
const api = await createCloudflareApi();
|
|
46
|
+
const api = await createCloudflareApi(props);
|
|
46
47
|
if (this.phase === "delete") {
|
|
47
48
|
// For delete operations, we need to check if the namespace ID exists in the output
|
|
48
49
|
const namespaceId = this.output?.namespaceId;
|
|
49
50
|
if (namespaceId) {
|
|
50
|
-
|
|
51
|
-
const deleteResponse = await api.delete(`/accounts/${api.accountId}/storage/kv/namespaces/${namespaceId}`);
|
|
52
|
-
if (!deleteResponse.ok && deleteResponse.status !== 404) {
|
|
53
|
-
const errorData = await deleteResponse.json().catch(() => ({
|
|
54
|
-
errors: [{ message: deleteResponse.statusText }],
|
|
55
|
-
}));
|
|
56
|
-
throw new Error(`Error deleting KV namespace '${props.title}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`);
|
|
57
|
-
}
|
|
51
|
+
await deleteKVNamespace(api, namespaceId);
|
|
58
52
|
}
|
|
59
53
|
// Return minimal output for deleted state
|
|
60
54
|
return this.destroy();
|
|
@@ -71,69 +65,11 @@ export const KVNamespace = Resource("cloudflare::KVNamespace", async function (i
|
|
|
71
65
|
}
|
|
72
66
|
else {
|
|
73
67
|
// TODO: if it already exists, then check the tags to see if we own it and continue
|
|
74
|
-
|
|
75
|
-
const createResponse = await api.post(`/accounts/${api.accountId}/storage/kv/namespaces`, {
|
|
76
|
-
title: props.title,
|
|
77
|
-
});
|
|
78
|
-
if (!createResponse.ok) {
|
|
79
|
-
const errorData = await createResponse.json().catch(() => ({
|
|
80
|
-
errors: [{ message: createResponse.statusText }],
|
|
81
|
-
}));
|
|
82
|
-
throw new Error(`Error creating KV namespace '${props.title}': ${errorData.errors?.[0]?.message || createResponse.statusText}`);
|
|
83
|
-
}
|
|
84
|
-
const createResult = await createResponse.json();
|
|
85
|
-
namespaceId = createResult.result.id;
|
|
68
|
+
const { id } = await createKVNamespace(api, props);
|
|
86
69
|
createdAt = Date.now();
|
|
70
|
+
namespaceId = id;
|
|
87
71
|
}
|
|
88
|
-
|
|
89
|
-
if (props.values && props.values.length > 0 && namespaceId) {
|
|
90
|
-
// Process KV pairs in batches of 10000 (API limit)
|
|
91
|
-
const BATCH_SIZE = 10000;
|
|
92
|
-
for (let i = 0; i < props.values.length; i += BATCH_SIZE) {
|
|
93
|
-
const batch = props.values.slice(i, i + BATCH_SIZE);
|
|
94
|
-
const bulkPayload = batch.map((entry) => {
|
|
95
|
-
const item = {
|
|
96
|
-
key: entry.key,
|
|
97
|
-
value: typeof entry.value === "string"
|
|
98
|
-
? entry.value
|
|
99
|
-
: JSON.stringify(entry.value),
|
|
100
|
-
};
|
|
101
|
-
if (entry.expiration) {
|
|
102
|
-
item.expiration = entry.expiration;
|
|
103
|
-
}
|
|
104
|
-
if (entry.expirationTtl) {
|
|
105
|
-
item.expiration_ttl = entry.expirationTtl;
|
|
106
|
-
}
|
|
107
|
-
if (entry.metadata) {
|
|
108
|
-
item.metadata = entry.metadata;
|
|
109
|
-
}
|
|
110
|
-
return item;
|
|
111
|
-
});
|
|
112
|
-
try {
|
|
113
|
-
await withExponentialBackoff(async () => {
|
|
114
|
-
const bulkResponse = await api.put(`/accounts/${api.accountId}/storage/kv/namespaces/${namespaceId}/bulk`, bulkPayload);
|
|
115
|
-
if (!bulkResponse.ok) {
|
|
116
|
-
const errorData = await bulkResponse
|
|
117
|
-
.json()
|
|
118
|
-
.catch(() => ({
|
|
119
|
-
errors: [{ message: bulkResponse.statusText }],
|
|
120
|
-
}));
|
|
121
|
-
const errorMessage = errorData.errors?.[0]?.message || bulkResponse.statusText;
|
|
122
|
-
// Throw error to trigger retry
|
|
123
|
-
throw new Error(`Error writing KV batch: ${errorMessage}`);
|
|
124
|
-
}
|
|
125
|
-
return bulkResponse;
|
|
126
|
-
}, (error) => {
|
|
127
|
-
// Retry on "namespace not found" errors as they're likely propagation delays
|
|
128
|
-
return error.message?.includes("not found");
|
|
129
|
-
}, 5, // 5 retry attempts
|
|
130
|
-
1000);
|
|
131
|
-
}
|
|
132
|
-
catch (error) {
|
|
133
|
-
console.warn(error.message);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
72
|
+
await insertKVRecords(api, namespaceId, props);
|
|
137
73
|
return this({
|
|
138
74
|
type: "kv_namespace",
|
|
139
75
|
namespaceId: namespaceId,
|
|
@@ -144,3 +80,68 @@ export const KVNamespace = Resource("cloudflare::KVNamespace", async function (i
|
|
|
144
80
|
});
|
|
145
81
|
}
|
|
146
82
|
});
|
|
83
|
+
export async function createKVNamespace(api, props) {
|
|
84
|
+
const createResponse = await api.post(`/accounts/${api.accountId}/storage/kv/namespaces`, {
|
|
85
|
+
title: props.title,
|
|
86
|
+
});
|
|
87
|
+
if (!createResponse.ok) {
|
|
88
|
+
await handleApiError(createResponse, "create", "kv_namespace", props.title);
|
|
89
|
+
}
|
|
90
|
+
return { id: (await createResponse.json()).result.id };
|
|
91
|
+
}
|
|
92
|
+
export async function deleteKVNamespace(api, namespaceId) {
|
|
93
|
+
// Delete KV namespace
|
|
94
|
+
const deleteResponse = await api.delete(`/accounts/${api.accountId}/storage/kv/namespaces/${namespaceId}`);
|
|
95
|
+
if (!deleteResponse.ok && deleteResponse.status !== 404) {
|
|
96
|
+
await handleApiError(deleteResponse, "delete", "kv_namespace", namespaceId);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function insertKVRecords(api, namespaceId, props) {
|
|
100
|
+
if (props.values && props.values.length > 0) {
|
|
101
|
+
// Process KV pairs in batches of 10000 (API limit)
|
|
102
|
+
const BATCH_SIZE = 10000;
|
|
103
|
+
for (let i = 0; i < props.values.length; i += BATCH_SIZE) {
|
|
104
|
+
const batch = props.values.slice(i, i + BATCH_SIZE);
|
|
105
|
+
const bulkPayload = batch.map((entry) => {
|
|
106
|
+
const item = {
|
|
107
|
+
key: entry.key,
|
|
108
|
+
value: typeof entry.value === "string"
|
|
109
|
+
? entry.value
|
|
110
|
+
: JSON.stringify(entry.value),
|
|
111
|
+
};
|
|
112
|
+
if (entry.expiration) {
|
|
113
|
+
item.expiration = entry.expiration;
|
|
114
|
+
}
|
|
115
|
+
if (entry.expirationTtl) {
|
|
116
|
+
item.expiration_ttl = entry.expirationTtl;
|
|
117
|
+
}
|
|
118
|
+
if (entry.metadata) {
|
|
119
|
+
item.metadata = entry.metadata;
|
|
120
|
+
}
|
|
121
|
+
return item;
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
await withExponentialBackoff(async () => {
|
|
125
|
+
const bulkResponse = await api.put(`/accounts/${api.accountId}/storage/kv/namespaces/${namespaceId}/bulk`, bulkPayload);
|
|
126
|
+
if (!bulkResponse.ok) {
|
|
127
|
+
const errorData = await bulkResponse.json().catch(() => ({
|
|
128
|
+
errors: [{ message: bulkResponse.statusText }],
|
|
129
|
+
}));
|
|
130
|
+
const errorMessage = errorData.errors?.[0]?.message || bulkResponse.statusText;
|
|
131
|
+
// Throw error to trigger retry
|
|
132
|
+
throw new Error(`Error writing KV batch: ${errorMessage}`);
|
|
133
|
+
}
|
|
134
|
+
return bulkResponse;
|
|
135
|
+
}, (error) => {
|
|
136
|
+
// Retry on "namespace not found" errors as they're likely propagation delays
|
|
137
|
+
return error.message?.includes("not found");
|
|
138
|
+
}, 5, // 5 retry attempts
|
|
139
|
+
1000 // Start with 1 second delay
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
console.warn(error.message);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -53,6 +53,10 @@ export type PermissionGroups = Resource<"cloudflare::PermissionGroups"> & {
|
|
|
53
53
|
*
|
|
54
54
|
* This is primarily used when creating API tokens for Cloudflare services like R2.
|
|
55
55
|
*
|
|
56
|
+
* Note: Requires a Cloudflare API Key or Token with account read access.
|
|
57
|
+
* The API token must have permission to read token permission groups.
|
|
58
|
+
* The OAuth token from `wrangler login` is NOT sufficient for this operation.
|
|
59
|
+
*
|
|
56
60
|
* @example
|
|
57
61
|
* // Get all permission groups including those for R2
|
|
58
62
|
* const permissions = await PermissionGroups("cloudflare-permissions");
|
|
@@ -6,6 +6,10 @@ import { createCloudflareApi } from "./api";
|
|
|
6
6
|
*
|
|
7
7
|
* This is primarily used when creating API tokens for Cloudflare services like R2.
|
|
8
8
|
*
|
|
9
|
+
* Note: Requires a Cloudflare API Key or Token with account read access.
|
|
10
|
+
* The API token must have permission to read token permission groups.
|
|
11
|
+
* The OAuth token from `wrangler login` is NOT sufficient for this operation.
|
|
12
|
+
*
|
|
9
13
|
* @example
|
|
10
14
|
* // Get all permission groups including those for R2
|
|
11
15
|
* const permissions = await PermissionGroups("cloudflare-permissions");
|
|
@@ -28,7 +32,7 @@ import { createCloudflareApi } from "./api";
|
|
|
28
32
|
* ]
|
|
29
33
|
* });
|
|
30
34
|
*/
|
|
31
|
-
export const PermissionGroups = Resource("cloudflare::PermissionGroups", async function (id, options) {
|
|
35
|
+
export const PermissionGroups = Resource("cloudflare::PermissionGroups", async function (id, options = {}) {
|
|
32
36
|
// Only create and update phases are supported
|
|
33
37
|
if (this.phase === "delete") {
|
|
34
38
|
return this.destroy();
|