alchemy 0.3.0 → 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.
- package/README.md +8 -3
- package/lib/ai/astro-file.js +7 -24
- package/lib/ai/client.d.ts +7 -0
- package/lib/ai/client.js +45 -0
- package/lib/ai/css-file.js +7 -24
- package/lib/ai/data.d.ts +26 -21
- package/lib/ai/data.js +33 -22
- package/lib/ai/document.d.ts +76 -5
- package/lib/ai/document.js +112 -56
- package/lib/ai/html-file.js +7 -24
- package/lib/ai/json-file.js +11 -32
- package/lib/ai/typescript-file.d.ts +1 -2
- package/lib/ai/typescript-file.js +8 -39
- package/lib/ai/vue-file.js +7 -24
- package/lib/ai/yaml-file.js +12 -48
- package/lib/alchemy.d.ts +5 -0
- package/lib/alchemy.js +61 -3
- package/lib/apply.js +18 -11
- package/lib/aws/account-id.d.ts +4 -1
- package/lib/aws/account-id.js +1 -1
- package/lib/aws/credentials.d.ts +5 -0
- package/lib/aws/credentials.js +0 -0
- package/lib/aws/oidc/oidc-provider.js +2 -2
- package/lib/aws/role.js +35 -23
- package/lib/cloudflare/account-api-token.d.ts +168 -0
- package/lib/cloudflare/account-api-token.js +179 -0
- package/lib/cloudflare/account-id.d.ts +1 -0
- package/lib/cloudflare/account-id.js +0 -0
- package/lib/cloudflare/api.d.ts +38 -1
- package/lib/cloudflare/api.js +55 -4
- package/lib/cloudflare/bucket.d.ts +118 -3
- package/lib/cloudflare/bucket.js +269 -89
- package/lib/cloudflare/custom-domain.d.ts +75 -0
- package/lib/cloudflare/custom-domain.js +154 -0
- package/lib/cloudflare/{dns.js → dns-records.js} +14 -1
- package/lib/cloudflare/generate-asset-manifest.js +1 -1
- package/lib/cloudflare/index.d.ts +5 -1
- package/lib/cloudflare/index.js +5 -1
- package/lib/cloudflare/permission-groups.d.ts +78 -0
- package/lib/cloudflare/permission-groups.js +48 -0
- package/lib/cloudflare/r2-rest-state-store.d.ts +2 -1
- package/lib/cloudflare/r2-rest-state-store.js +3 -2
- package/lib/cloudflare/static-site-router.js +16 -7
- package/lib/cloudflare/static-site.d.ts +17 -18
- package/lib/cloudflare/static-site.js +19 -7
- package/lib/cloudflare/upload-asset-manifest.js +4 -2
- package/lib/{util/encrypt.d.ts → encrypt.d.ts} +1 -1
- package/lib/{util/encrypt.js → encrypt.js} +1 -1
- package/lib/fs/copy-file.d.ts +54 -0
- package/lib/fs/copy-file.js +63 -0
- package/lib/fs/file-system-state-store.js +1 -1
- package/lib/fs/file.d.ts +31 -0
- package/lib/fs/file.js +30 -5
- package/lib/fs/index.d.ts +6 -0
- package/lib/fs/index.js +6 -0
- package/lib/fs/static-astro-file.d.ts +34 -0
- package/lib/fs/static-astro-file.js +39 -0
- package/lib/fs/static-css-file.d.ts +24 -0
- package/lib/fs/static-css-file.js +29 -0
- package/lib/fs/static-html-file.d.ts +31 -0
- package/lib/fs/static-html-file.js +36 -0
- package/lib/fs/static-json-file.d.ts +2 -2
- package/lib/fs/static-json-file.js +3 -2
- package/lib/fs/static-text-file.d.ts +1 -1
- package/lib/fs/static-text-file.js +3 -2
- package/lib/fs/static-typescript-file.d.ts +2 -2
- package/lib/fs/static-typescript-file.js +3 -2
- package/lib/fs/static-vue-file.d.ts +28 -0
- package/lib/fs/static-vue-file.js +33 -0
- package/lib/fs/static-yaml-file.d.ts +11 -8
- package/lib/fs/static-yaml-file.js +3 -2
- package/lib/internal/{providers.d.ts → docs/providers.d.ts} +14 -4
- package/lib/internal/docs/providers.js +196 -0
- package/lib/secret.d.ts +3 -0
- package/lib/secret.js +13 -0
- package/lib/{util/serde.d.ts → serde.d.ts} +1 -1
- package/lib/{util/serde.js → serde.js} +12 -4
- package/lib/test/bun.js +3 -2
- package/lib/util/sha256.d.ts +1 -0
- package/lib/util/sha256.js +4 -0
- package/lib/web/vitepress/config.d.ts +11 -0
- package/lib/web/vitepress/config.js +32 -0
- package/lib/web/vitepress/home-page.d.ts +5 -3
- package/lib/web/vitepress/home-page.js +48 -35
- package/lib/web/vitepress/index.d.ts +2 -0
- package/lib/web/vitepress/index.js +2 -0
- package/lib/web/vitepress/process-front-matter-files.d.ts +18 -0
- package/lib/web/vitepress/process-front-matter-files.js +68 -0
- package/lib/web/vitepress/vitepress.d.ts +5 -4
- package/lib/web/vitepress/vitepress.js +20 -36
- package/package.json +8 -4
- package/src/ai/astro-file.ts +11 -30
- package/src/ai/client.ts +59 -0
- package/src/ai/css-file.ts +11 -30
- package/src/ai/data.ts +50 -25
- package/src/ai/document.ts +171 -66
- package/src/ai/html-file.ts +11 -30
- package/src/ai/json-file.ts +13 -37
- package/src/ai/typescript-file.ts +14 -48
- package/src/ai/vue-file.ts +11 -30
- package/src/ai/yaml-file.ts +13 -53
- package/src/alchemy.ts +74 -9
- package/src/apply.ts +27 -26
- package/src/aws/account-id.ts +6 -2
- package/src/aws/credentials.ts +6 -0
- package/src/aws/oidc/oidc-provider.ts +17 -17
- package/src/aws/role.ts +68 -54
- package/src/cloudflare/account-api-token.ts +365 -0
- package/src/cloudflare/account-id.ts +0 -0
- package/src/cloudflare/api.ts +88 -17
- package/src/cloudflare/bucket.ts +493 -133
- package/src/cloudflare/custom-domain.ts +318 -0
- package/src/cloudflare/{dns.ts → dns-records.ts} +36 -22
- package/src/cloudflare/generate-asset-manifest.ts +4 -4
- package/src/cloudflare/index.ts +5 -1
- package/src/cloudflare/permission-groups.ts +137 -0
- package/src/cloudflare/r2-rest-state-store.ts +14 -13
- package/src/cloudflare/static-site-router.ts +18 -8
- package/src/cloudflare/static-site.ts +27 -35
- package/src/cloudflare/upload-asset-manifest.ts +6 -4
- package/src/cloudflare/worker.ts +44 -43
- package/src/dns/import-dns.ts +4 -4
- package/src/{util/encrypt.ts → encrypt.ts} +7 -10
- package/src/fs/copy-file.ts +115 -0
- package/src/fs/file-system-state-store.ts +5 -5
- package/src/fs/file.ts +64 -9
- package/src/fs/index.ts +6 -0
- package/src/fs/static-astro-file.ts +45 -0
- package/src/fs/static-css-file.ts +35 -0
- package/src/fs/static-html-file.ts +42 -0
- package/src/fs/static-json-file.ts +4 -3
- package/src/fs/static-text-file.ts +5 -2
- package/src/fs/static-typescript-file.ts +4 -3
- package/src/fs/static-vue-file.ts +39 -0
- package/src/fs/static-yaml-file.ts +13 -9
- package/src/internal/docs/providers.ts +281 -0
- package/src/secret.ts +16 -0
- package/src/{util/serde.ts → serde.ts} +16 -10
- package/src/test/bun.ts +8 -7
- package/src/util/sha256.ts +5 -0
- package/src/web/vitepress/config.ts +50 -0
- package/src/web/vitepress/home-page.ts +54 -42
- package/src/web/vitepress/index.ts +2 -0
- package/src/web/vitepress/process-front-matter-files.ts +98 -0
- package/src/web/vitepress/vitepress.ts +34 -50
- package/lib/internal/getting-started.d.ts +0 -10
- package/lib/internal/getting-started.js +0 -94
- package/lib/internal/project.d.ts +0 -13
- package/lib/internal/project.js +0 -101
- package/lib/internal/providers.js +0 -148
- package/src/internal/getting-started.ts +0 -107
- package/src/internal/project.ts +0 -119
- package/src/internal/providers.ts +0 -209
- /package/lib/cloudflare/{dns.d.ts → dns-records.d.ts} +0 -0
package/lib/alchemy.js
CHANGED
|
@@ -13,11 +13,17 @@ export const alchemy = _alchemy;
|
|
|
13
13
|
function _alchemy(...args) {
|
|
14
14
|
if (typeof args[0] === "string") {
|
|
15
15
|
const [appName, options] = args;
|
|
16
|
-
|
|
16
|
+
const root = scope(undefined, {
|
|
17
17
|
...options,
|
|
18
18
|
appName,
|
|
19
19
|
stage: options?.stage,
|
|
20
20
|
});
|
|
21
|
+
if (options?.phase === "destroy") {
|
|
22
|
+
return destroy(root).finally(() => {
|
|
23
|
+
process.exit(0);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return root;
|
|
21
27
|
}
|
|
22
28
|
else {
|
|
23
29
|
const [template, ...values] = args;
|
|
@@ -33,6 +39,17 @@ function _alchemy(...args) {
|
|
|
33
39
|
if (typeof value === "string") {
|
|
34
40
|
return indent + value;
|
|
35
41
|
}
|
|
42
|
+
else if (value === null) {
|
|
43
|
+
return "null";
|
|
44
|
+
}
|
|
45
|
+
else if (value === undefined) {
|
|
46
|
+
return "undefined";
|
|
47
|
+
}
|
|
48
|
+
else if (typeof value === "number" ||
|
|
49
|
+
typeof value === "boolean" ||
|
|
50
|
+
typeof value === "bigint") {
|
|
51
|
+
return value.toString();
|
|
52
|
+
}
|
|
36
53
|
else if (value instanceof Promise) {
|
|
37
54
|
return resolve(await value);
|
|
38
55
|
}
|
|
@@ -53,9 +70,26 @@ function _alchemy(...args) {
|
|
|
53
70
|
else if (Array.isArray(value)) {
|
|
54
71
|
return (await Promise.all(value.map(async (value, i) => `${i}. ${await resolve(value)}`))).join("\n");
|
|
55
72
|
}
|
|
73
|
+
else if (typeof value === "object" &&
|
|
74
|
+
typeof value.path === "string") {
|
|
75
|
+
if (typeof value.content === "string") {
|
|
76
|
+
appendices[value.path] = value.content;
|
|
77
|
+
return `[${path.basename(value.path)}](${value.path})`;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
appendices[value.path] = await fs.readFile(value.path, "utf-8");
|
|
81
|
+
return `[${path.basename(value.path)}](${value.path})`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (typeof value === "object") {
|
|
85
|
+
return (await Promise.all(Object.entries(value).map(async ([key, value]) => {
|
|
86
|
+
return `* ${key}: ${await resolve(value)}`;
|
|
87
|
+
}))).join("\n");
|
|
88
|
+
}
|
|
56
89
|
else {
|
|
57
90
|
// TODO: support other types
|
|
58
|
-
|
|
91
|
+
console.log(value);
|
|
92
|
+
throw new Error(`Unsupported value type: ${value}`);
|
|
59
93
|
}
|
|
60
94
|
}));
|
|
61
95
|
// Construct the string template by joining template parts with interpolated values
|
|
@@ -90,6 +124,7 @@ _alchemy.destroy = destroy;
|
|
|
90
124
|
_alchemy.run = run;
|
|
91
125
|
_alchemy.scope = scope;
|
|
92
126
|
_alchemy.secret = secret;
|
|
127
|
+
_alchemy.env = env;
|
|
93
128
|
/**
|
|
94
129
|
* Enter a new scope synchronously.
|
|
95
130
|
*
|
|
@@ -104,7 +139,10 @@ _alchemy.secret = secret;
|
|
|
104
139
|
* apiKey: alchemy.secret(process.env.API_KEY)
|
|
105
140
|
* });
|
|
106
141
|
*/
|
|
107
|
-
function scope(id, options
|
|
142
|
+
function scope(id, options
|
|
143
|
+
// TODO: maybe we want to allow using _ = await alchemy.scope(import.meta)
|
|
144
|
+
// | [meta: ImportMeta]
|
|
145
|
+
) {
|
|
108
146
|
const scope = new Scope({
|
|
109
147
|
...options,
|
|
110
148
|
appName: options?.appName,
|
|
@@ -150,3 +188,23 @@ async function run(...args) {
|
|
|
150
188
|
await scope.finalize();
|
|
151
189
|
}
|
|
152
190
|
}
|
|
191
|
+
export async function env(name, value, error) {
|
|
192
|
+
if (value !== undefined) {
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
else if (typeof process !== undefined) {
|
|
196
|
+
// we are in a node environment
|
|
197
|
+
return process.env[name];
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
// we are in a browser environment
|
|
201
|
+
try {
|
|
202
|
+
const { env } = await import("cloudflare:workers");
|
|
203
|
+
if (name in env) {
|
|
204
|
+
return env[name];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (error) { }
|
|
208
|
+
}
|
|
209
|
+
throw new Error(error ?? `Environment variable ${name} is not set`);
|
|
210
|
+
}
|
package/lib/apply.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { alchemy } from "./alchemy";
|
|
2
2
|
import { context } from "./context";
|
|
3
3
|
import { PROVIDERS, } from "./resource";
|
|
4
|
-
import {
|
|
5
|
-
import { serialize } from "./util/serde";
|
|
4
|
+
import { serialize } from "./serde";
|
|
6
5
|
export async function apply(resource, props, options) {
|
|
7
6
|
const scope = resource.Scope;
|
|
8
7
|
try {
|
|
@@ -36,20 +35,27 @@ export async function apply(resource, props, options) {
|
|
|
36
35
|
const alwaysUpdate = options?.alwaysUpdate ?? provider.options?.alwaysUpdate ?? false;
|
|
37
36
|
// Skip update if inputs haven't changed and resource is in a stable state
|
|
38
37
|
if (state.status === "created" || state.status === "updated") {
|
|
39
|
-
const oldProps =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
: value, 2);
|
|
44
|
-
const newProps = JSON.stringify(await serialize(scope, props, {
|
|
38
|
+
const oldProps = await serialize(scope, state.props, {
|
|
39
|
+
encrypt: false,
|
|
40
|
+
});
|
|
41
|
+
const newProps = await serialize(scope, props, {
|
|
45
42
|
encrypt: false,
|
|
46
|
-
})
|
|
47
|
-
if (oldProps === newProps &&
|
|
43
|
+
});
|
|
44
|
+
if (JSON.stringify(oldProps) === JSON.stringify(newProps) &&
|
|
45
|
+
alwaysUpdate !== true) {
|
|
48
46
|
if (!quiet) {
|
|
49
|
-
console.log(`Skip: "${resource.FQN}" (no changes)`);
|
|
47
|
+
// console.log(`Skip: "${resource.FQN}" (no changes)`);
|
|
50
48
|
}
|
|
51
49
|
return state.output;
|
|
52
50
|
}
|
|
51
|
+
const diff = await import("diff");
|
|
52
|
+
const yaml = await import("yaml");
|
|
53
|
+
const old = yaml.stringify(oldProps);
|
|
54
|
+
const news = yaml.stringify(newProps);
|
|
55
|
+
const patch = diff.createPatch(resource.ID, old, news);
|
|
56
|
+
// console.log(patch);
|
|
57
|
+
// if (resource.ID === "what-is-alchemy") {
|
|
58
|
+
// }
|
|
53
59
|
}
|
|
54
60
|
const phase = state.status === "creating" ? "create" : "update";
|
|
55
61
|
state.status = phase === "create" ? "creating" : "updating";
|
|
@@ -98,6 +104,7 @@ export async function apply(resource, props, options) {
|
|
|
98
104
|
return output;
|
|
99
105
|
}
|
|
100
106
|
catch (error) {
|
|
107
|
+
console.error(new Error().stack);
|
|
101
108
|
scope.fail();
|
|
102
109
|
throw error;
|
|
103
110
|
}
|
package/lib/aws/account-id.d.ts
CHANGED
package/lib/aws/account-id.js
CHANGED
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CreateOpenIDConnectProviderCommand, DeleteOpenIDConnectProviderCommand, GetOpenIDConnectProviderCommand, GetRoleCommand, IAMClient, UpdateAssumeRolePolicyCommand, } from "@aws-sdk/client-iam";
|
|
2
2
|
import { Resource } from "../../resource";
|
|
3
|
-
import {
|
|
3
|
+
import { AccountId } from "../account-id";
|
|
4
4
|
/**
|
|
5
5
|
* AWS OIDC Provider Resource for GitHub Actions
|
|
6
6
|
*
|
|
@@ -91,7 +91,7 @@ export const OIDCProvider = Resource("aws::OIDCProvider", async function (id, pr
|
|
|
91
91
|
catch (error) {
|
|
92
92
|
if (error.name === "EntityAlreadyExistsException") {
|
|
93
93
|
// Provider exists, use its ARN
|
|
94
|
-
providerArn = `arn:aws:iam::${await
|
|
94
|
+
providerArn = `arn:aws:iam::${await AccountId()}:oidc-provider/${url}`;
|
|
95
95
|
}
|
|
96
96
|
else {
|
|
97
97
|
throw error;
|
package/lib/aws/role.js
CHANGED
|
@@ -118,38 +118,50 @@ import { ignore } from "../util/ignore";
|
|
|
118
118
|
export const Role = Resource("iam::Role", async function (id, props) {
|
|
119
119
|
const client = new IAMClient({});
|
|
120
120
|
if (this.phase === "delete") {
|
|
121
|
-
// Delete any inline policies first
|
|
122
|
-
if (props.policies) {
|
|
123
|
-
for (const policy of props.policies) {
|
|
124
|
-
await ignore(NoSuchEntityException.name, () => client.send(new DeleteRolePolicyCommand({
|
|
125
|
-
RoleName: props.roleName,
|
|
126
|
-
PolicyName: policy.policyName,
|
|
127
|
-
})));
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
// We need to detach managed policies before deleting the role
|
|
131
|
-
// First, get all attached policies
|
|
132
121
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
122
|
+
// Delete any inline policies first
|
|
123
|
+
if (props.policies) {
|
|
124
|
+
for (const policy of props.policies) {
|
|
125
|
+
await ignore(NoSuchEntityException.name, () => client.send(new DeleteRolePolicyCommand({
|
|
126
|
+
RoleName: props.roleName,
|
|
127
|
+
PolicyName: policy.policyName,
|
|
128
|
+
})));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// We need to detach managed policies before deleting the role
|
|
132
|
+
// First, get all attached policies
|
|
133
|
+
try {
|
|
134
|
+
const attachedPoliciesResponse = await client.send(new ListAttachedRolePoliciesCommand({
|
|
140
135
|
RoleName: props.roleName,
|
|
141
|
-
PolicyArn: policy.PolicyArn,
|
|
142
136
|
}));
|
|
137
|
+
// Detach all managed policies
|
|
138
|
+
const attachedPolicies = attachedPoliciesResponse.AttachedPolicies || [];
|
|
139
|
+
for (const policy of attachedPolicies) {
|
|
140
|
+
await ignore(NoSuchEntityException.name, () => client.send(new DetachRolePolicyCommand({
|
|
141
|
+
RoleName: props.roleName,
|
|
142
|
+
PolicyArn: policy.PolicyArn,
|
|
143
|
+
})));
|
|
144
|
+
}
|
|
143
145
|
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error.name !== NoSuchEntityException.name) {
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
// Role doesn't exist, no need to continue with detaching policies
|
|
151
|
+
}
|
|
152
|
+
// Try to delete the role, ignoring if it doesn't exist
|
|
153
|
+
await ignore(NoSuchEntityException.name, () => client.send(new DeleteRoleCommand({
|
|
154
|
+
RoleName: props.roleName,
|
|
155
|
+
})));
|
|
144
156
|
}
|
|
145
157
|
catch (error) {
|
|
158
|
+
// If we get any other error besides NoSuchEntityException, log it but don't fail
|
|
159
|
+
// This ensures the resource is still marked as destroyed
|
|
146
160
|
if (error.name !== NoSuchEntityException.name) {
|
|
147
|
-
|
|
161
|
+
console.error(`Error deleting role ${props.roleName}:`, error);
|
|
148
162
|
}
|
|
149
163
|
}
|
|
150
|
-
|
|
151
|
-
RoleName: props.roleName,
|
|
152
|
-
})));
|
|
164
|
+
// Always return destroyed state regardless of any errors
|
|
153
165
|
return this.destroy();
|
|
154
166
|
}
|
|
155
167
|
const assumeRolePolicyDocument = JSON.stringify(props.assumeRolePolicy);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Context } from "../context";
|
|
2
|
+
import { Resource } from "../resource";
|
|
3
|
+
import { Secret } from "../secret";
|
|
4
|
+
/**
|
|
5
|
+
* Permission group for a token policy
|
|
6
|
+
*/
|
|
7
|
+
export interface TokenPolicyPermissionGroup {
|
|
8
|
+
/**
|
|
9
|
+
* ID of the permission group
|
|
10
|
+
*/
|
|
11
|
+
id: string;
|
|
12
|
+
/**
|
|
13
|
+
* Optional metadata for the permission group
|
|
14
|
+
*/
|
|
15
|
+
meta?: Record<string, any>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Policy that defines what the token can access
|
|
19
|
+
*/
|
|
20
|
+
export interface TokenPolicy {
|
|
21
|
+
/**
|
|
22
|
+
* Effect of the policy
|
|
23
|
+
*/
|
|
24
|
+
effect: "allow" | "deny";
|
|
25
|
+
/**
|
|
26
|
+
* Permission groups to include in the policy
|
|
27
|
+
*/
|
|
28
|
+
permissionGroups: TokenPolicyPermissionGroup[];
|
|
29
|
+
/**
|
|
30
|
+
* Resources the policy applies to
|
|
31
|
+
*/
|
|
32
|
+
resources: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Condition for token usage (e.g., IP restrictions)
|
|
36
|
+
*/
|
|
37
|
+
export interface TokenCondition {
|
|
38
|
+
/**
|
|
39
|
+
* IP address conditions
|
|
40
|
+
*/
|
|
41
|
+
requestIp?: {
|
|
42
|
+
/**
|
|
43
|
+
* IP ranges to allow
|
|
44
|
+
*/
|
|
45
|
+
in?: string[];
|
|
46
|
+
/**
|
|
47
|
+
* IP ranges to deny
|
|
48
|
+
*/
|
|
49
|
+
notIn?: string[];
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Properties for creating or updating an Account API Token
|
|
54
|
+
*/
|
|
55
|
+
export interface AccountApiTokenProps {
|
|
56
|
+
/**
|
|
57
|
+
* Name of the token
|
|
58
|
+
*/
|
|
59
|
+
name: string;
|
|
60
|
+
/**
|
|
61
|
+
* Policies that define what the token can access
|
|
62
|
+
*/
|
|
63
|
+
policies: TokenPolicy[];
|
|
64
|
+
/**
|
|
65
|
+
* Optional expiration date for the token (ISO format)
|
|
66
|
+
*/
|
|
67
|
+
expiresOn?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Optional "not before" date (token is not valid before this date) (ISO format)
|
|
70
|
+
*/
|
|
71
|
+
notBefore?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Optional conditions for token use (like IP restrictions)
|
|
74
|
+
*/
|
|
75
|
+
condition?: TokenCondition;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Output returned after Account API Token creation/update
|
|
79
|
+
*/
|
|
80
|
+
export interface AccountApiToken extends Resource<"cloudflare::AccountApiToken">, AccountApiTokenProps {
|
|
81
|
+
/**
|
|
82
|
+
* The ID of the token
|
|
83
|
+
*
|
|
84
|
+
* Equiv. to ACCESS_KEY_ID
|
|
85
|
+
*/
|
|
86
|
+
id: string;
|
|
87
|
+
/**
|
|
88
|
+
* Status of the token
|
|
89
|
+
*/
|
|
90
|
+
status: string;
|
|
91
|
+
/**
|
|
92
|
+
* Actual token value (only available on creation)
|
|
93
|
+
* Stored as a Secret for security
|
|
94
|
+
*
|
|
95
|
+
* Equiv. to SECRET_ACCESS_KEY
|
|
96
|
+
*/
|
|
97
|
+
value?: Secret;
|
|
98
|
+
/**
|
|
99
|
+
* Access key ID for the token
|
|
100
|
+
*
|
|
101
|
+
* An alias of {@link id}
|
|
102
|
+
*/
|
|
103
|
+
accessKeyId: string;
|
|
104
|
+
/**
|
|
105
|
+
* Secret access key for the token
|
|
106
|
+
*
|
|
107
|
+
* The SHA-256 hash of the token {@link value}
|
|
108
|
+
*
|
|
109
|
+
* @see https://developers.cloudflare.com/r2/api/tokens/#get-s3-api-credentials-from-an-api-token
|
|
110
|
+
*/
|
|
111
|
+
secretAccessKey: string;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Creates a Cloudflare Account API Token with specified permissions.
|
|
115
|
+
*
|
|
116
|
+
* @see https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/methods/create/
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* // First, fetch all permission groups
|
|
120
|
+
* const permissions = await PermissionGroups("cloudflare-permissions", {
|
|
121
|
+
* accountId: cfAccountId,
|
|
122
|
+
* });
|
|
123
|
+
*
|
|
124
|
+
* // Create a token with read-only permissions for specific zones
|
|
125
|
+
* const readOnlyToken = await AccountApiToken("readonly-token", {
|
|
126
|
+
* name: "Readonly Zone Token",
|
|
127
|
+
* policies: [
|
|
128
|
+
* {
|
|
129
|
+
* effect: "allow",
|
|
130
|
+
* permissionGroups: [
|
|
131
|
+
* { id: permissions["Zone Read"].id },
|
|
132
|
+
* { id: permissions["Analytics Read"].id }
|
|
133
|
+
* ],
|
|
134
|
+
* resources: {
|
|
135
|
+
* "com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43": "*",
|
|
136
|
+
* "com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4": "*"
|
|
137
|
+
* }
|
|
138
|
+
* }
|
|
139
|
+
* ],
|
|
140
|
+
* expiresOn: "2024-12-31T23:59:59Z"
|
|
141
|
+
* });
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* // Create a token with time and IP restrictions
|
|
145
|
+
* const restrictedToken = await AccountApiToken("restricted-token", {
|
|
146
|
+
* name: "Restricted Access Token",
|
|
147
|
+
* policies: [
|
|
148
|
+
* {
|
|
149
|
+
* effect: "allow",
|
|
150
|
+
* permissionGroups: [
|
|
151
|
+
* { id: permissions["Worker Routes Edit"].id }
|
|
152
|
+
* ],
|
|
153
|
+
* resources: {
|
|
154
|
+
* "com.cloudflare.api.account.worker.route.*": "*"
|
|
155
|
+
* }
|
|
156
|
+
* }
|
|
157
|
+
* ],
|
|
158
|
+
* notBefore: "2023-01-01T00:00:00Z",
|
|
159
|
+
* expiresOn: "2023-12-31T23:59:59Z",
|
|
160
|
+
* condition: {
|
|
161
|
+
* requestIp: {
|
|
162
|
+
* in: ["192.168.1.0/24", "10.0.0.0/8"],
|
|
163
|
+
* notIn: ["192.168.1.100/32"]
|
|
164
|
+
* }
|
|
165
|
+
* }
|
|
166
|
+
* });
|
|
167
|
+
*/
|
|
168
|
+
export declare const AccountApiToken: (((this: any, id: string, props?: {}) => never) & (new (_: never) => never)) | ((this: Context<AccountApiToken>, id: string, props: AccountApiTokenProps) => Promise<AccountApiToken>);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { alchemy } from "../alchemy";
|
|
2
|
+
import { Resource } from "../resource";
|
|
3
|
+
import { Secret } from "../secret";
|
|
4
|
+
import { sha256 } from "../util/sha256";
|
|
5
|
+
import { createCloudflareApi } from "./api";
|
|
6
|
+
/**
|
|
7
|
+
* Creates a Cloudflare Account API Token with specified permissions.
|
|
8
|
+
*
|
|
9
|
+
* @see https://developers.cloudflare.com/api/resources/accounts/subresources/tokens/methods/create/
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // First, fetch all permission groups
|
|
13
|
+
* const permissions = await PermissionGroups("cloudflare-permissions", {
|
|
14
|
+
* accountId: cfAccountId,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // Create a token with read-only permissions for specific zones
|
|
18
|
+
* const readOnlyToken = await AccountApiToken("readonly-token", {
|
|
19
|
+
* name: "Readonly Zone Token",
|
|
20
|
+
* policies: [
|
|
21
|
+
* {
|
|
22
|
+
* effect: "allow",
|
|
23
|
+
* permissionGroups: [
|
|
24
|
+
* { id: permissions["Zone Read"].id },
|
|
25
|
+
* { id: permissions["Analytics Read"].id }
|
|
26
|
+
* ],
|
|
27
|
+
* resources: {
|
|
28
|
+
* "com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43": "*",
|
|
29
|
+
* "com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4": "*"
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ],
|
|
33
|
+
* expiresOn: "2024-12-31T23:59:59Z"
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* // Create a token with time and IP restrictions
|
|
38
|
+
* const restrictedToken = await AccountApiToken("restricted-token", {
|
|
39
|
+
* name: "Restricted Access Token",
|
|
40
|
+
* policies: [
|
|
41
|
+
* {
|
|
42
|
+
* effect: "allow",
|
|
43
|
+
* permissionGroups: [
|
|
44
|
+
* { id: permissions["Worker Routes Edit"].id }
|
|
45
|
+
* ],
|
|
46
|
+
* resources: {
|
|
47
|
+
* "com.cloudflare.api.account.worker.route.*": "*"
|
|
48
|
+
* }
|
|
49
|
+
* }
|
|
50
|
+
* ],
|
|
51
|
+
* notBefore: "2023-01-01T00:00:00Z",
|
|
52
|
+
* expiresOn: "2023-12-31T23:59:59Z",
|
|
53
|
+
* condition: {
|
|
54
|
+
* requestIp: {
|
|
55
|
+
* in: ["192.168.1.0/24", "10.0.0.0/8"],
|
|
56
|
+
* notIn: ["192.168.1.100/32"]
|
|
57
|
+
* }
|
|
58
|
+
* }
|
|
59
|
+
* });
|
|
60
|
+
*/
|
|
61
|
+
export const AccountApiToken = Resource("cloudflare::AccountApiToken", async function (id, props) {
|
|
62
|
+
// Create Cloudflare API client with automatic account discovery
|
|
63
|
+
const api = await createCloudflareApi();
|
|
64
|
+
if (this.phase === "delete") {
|
|
65
|
+
// Delete token if we have an ID
|
|
66
|
+
if (this.output?.id) {
|
|
67
|
+
try {
|
|
68
|
+
const deleteResponse = await api.delete(`/accounts/${api.accountId}/tokens/${this.output.id}`);
|
|
69
|
+
if (!deleteResponse.ok && deleteResponse.status !== 404) {
|
|
70
|
+
const errorData = await deleteResponse.json().catch(() => ({
|
|
71
|
+
errors: [{ message: deleteResponse.statusText }],
|
|
72
|
+
}));
|
|
73
|
+
console.error(`Error deleting token '${props.name}':`, errorData);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error(`Error deleting token '${props.name}':`, error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Return destroyed state
|
|
81
|
+
return this.destroy();
|
|
82
|
+
}
|
|
83
|
+
// Transform our properties to API format
|
|
84
|
+
const apiPayload = {
|
|
85
|
+
name: props.name,
|
|
86
|
+
policies: props.policies.map((policy) => ({
|
|
87
|
+
effect: policy.effect,
|
|
88
|
+
permission_groups: policy.permissionGroups.map((pg) => ({
|
|
89
|
+
id: pg.id,
|
|
90
|
+
meta: pg.meta || {},
|
|
91
|
+
})),
|
|
92
|
+
resources: policy.resources,
|
|
93
|
+
})),
|
|
94
|
+
// Format dates for Cloudflare API (removing milliseconds)
|
|
95
|
+
...(props.expiresOn
|
|
96
|
+
? { expires_on: formatCloudflareDate(props.expiresOn) }
|
|
97
|
+
: {}),
|
|
98
|
+
...(props.notBefore
|
|
99
|
+
? { not_before: formatCloudflareDate(props.notBefore) }
|
|
100
|
+
: {}),
|
|
101
|
+
...(props.condition
|
|
102
|
+
? {
|
|
103
|
+
condition: {
|
|
104
|
+
request_ip: props.condition.requestIp
|
|
105
|
+
? {
|
|
106
|
+
in: props.condition.requestIp.in || [],
|
|
107
|
+
not_in: props.condition.requestIp.notIn || [],
|
|
108
|
+
}
|
|
109
|
+
: undefined,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Formats a date string for Cloudflare API by removing milliseconds
|
|
116
|
+
* Converts from "2023-01-01T00:00:00.000Z" to "2023-01-01T00:00:00Z"
|
|
117
|
+
*/
|
|
118
|
+
function formatCloudflareDate(dateStr) {
|
|
119
|
+
return dateStr.replace(/\.\d{3}Z$/, "Z");
|
|
120
|
+
}
|
|
121
|
+
let response;
|
|
122
|
+
let tokenValue;
|
|
123
|
+
if (this.phase === "update" && this.output?.id) {
|
|
124
|
+
// Update existing token
|
|
125
|
+
response = await api.put(`/accounts/${api.accountId}/tokens/${this.output.id}`, apiPayload);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// Create new token
|
|
129
|
+
response = await api.post(`/accounts/${api.accountId}/tokens`, apiPayload);
|
|
130
|
+
}
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const errorData = await response.json().catch(() => ({
|
|
133
|
+
errors: [{ message: response.statusText }],
|
|
134
|
+
}));
|
|
135
|
+
throw new Error(`Error ${this.phase === "update" ? "updating" : "creating"} token '${props.name}': ${errorData.errors?.[0]?.message || response.statusText}`);
|
|
136
|
+
}
|
|
137
|
+
const result = await response.json();
|
|
138
|
+
const tokenData = result.result;
|
|
139
|
+
if (tokenData.value) {
|
|
140
|
+
tokenValue = alchemy.secret(tokenData.value);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
if (!this.output?.value) {
|
|
144
|
+
throw new Error(`Token '${props.name}' was created but we have no record of its value. Try deleting and recreating the token.`);
|
|
145
|
+
}
|
|
146
|
+
tokenValue = this.output?.value;
|
|
147
|
+
}
|
|
148
|
+
// Transform API response to our format
|
|
149
|
+
return this({
|
|
150
|
+
id: tokenData.id,
|
|
151
|
+
name: tokenData.name,
|
|
152
|
+
status: tokenData.status,
|
|
153
|
+
policies: tokenData.policies.map((policy) => ({
|
|
154
|
+
effect: policy.effect,
|
|
155
|
+
permissionGroups: policy.permission_groups.map((pg) => ({
|
|
156
|
+
id: pg.id,
|
|
157
|
+
meta: pg.meta,
|
|
158
|
+
})),
|
|
159
|
+
resources: policy.resources,
|
|
160
|
+
})),
|
|
161
|
+
...(tokenData.expires_on ? { expiresOn: tokenData.expires_on } : {}),
|
|
162
|
+
...(tokenData.not_before ? { notBefore: tokenData.not_before } : {}),
|
|
163
|
+
...(tokenData.condition
|
|
164
|
+
? {
|
|
165
|
+
condition: {
|
|
166
|
+
requestIp: tokenData.condition.request_ip
|
|
167
|
+
? {
|
|
168
|
+
in: tokenData.condition.request_ip.in || [],
|
|
169
|
+
notIn: tokenData.condition.request_ip.not_in || [],
|
|
170
|
+
}
|
|
171
|
+
: undefined,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
: {}),
|
|
175
|
+
value: tokenValue,
|
|
176
|
+
accessKeyId: tokenData.id,
|
|
177
|
+
secretAccessKey: sha256(tokenValue.unencrypted),
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
File without changes
|