alchemy 0.93.6 → 0.93.9
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/bin/alchemy.js +1966 -1825
- package/bin/alchemy.ts +2 -0
- package/bin/commands/state.ts +58 -0
- package/bin/services/execute-alchemy.ts +9 -1
- package/lib/alchemy.d.ts.map +1 -1
- package/lib/alchemy.js +7 -0
- package/lib/alchemy.js.map +1 -1
- package/lib/cloudflare/access-application.d.ts +204 -0
- package/lib/cloudflare/access-application.d.ts.map +1 -0
- package/lib/cloudflare/access-application.js +205 -0
- package/lib/cloudflare/access-application.js.map +1 -0
- package/lib/cloudflare/access-group.d.ts +115 -0
- package/lib/cloudflare/access-group.d.ts.map +1 -0
- package/lib/cloudflare/access-group.js +147 -0
- package/lib/cloudflare/access-group.js.map +1 -0
- package/lib/cloudflare/access-identity-provider.d.ts +261 -0
- package/lib/cloudflare/access-identity-provider.d.ts.map +1 -0
- package/lib/cloudflare/access-identity-provider.js +229 -0
- package/lib/cloudflare/access-identity-provider.js.map +1 -0
- package/lib/cloudflare/access-policy.d.ts +168 -0
- package/lib/cloudflare/access-policy.d.ts.map +1 -0
- package/lib/cloudflare/access-policy.js +176 -0
- package/lib/cloudflare/access-policy.js.map +1 -0
- package/lib/cloudflare/access-rule.d.ts +248 -0
- package/lib/cloudflare/access-rule.d.ts.map +1 -0
- package/lib/cloudflare/access-rule.js +37 -0
- package/lib/cloudflare/access-rule.js.map +1 -0
- package/lib/cloudflare/access-service-token.d.ts +86 -0
- package/lib/cloudflare/access-service-token.d.ts.map +1 -0
- package/lib/cloudflare/access-service-token.js +127 -0
- package/lib/cloudflare/access-service-token.js.map +1 -0
- package/lib/cloudflare/index.d.ts +6 -0
- package/lib/cloudflare/index.d.ts.map +1 -1
- package/lib/cloudflare/index.js +6 -0
- package/lib/cloudflare/index.js.map +1 -1
- package/lib/cloudflare/miniflare/tunnel.d.ts.map +1 -1
- package/lib/cloudflare/miniflare/tunnel.js +10 -5
- package/lib/cloudflare/miniflare/tunnel.js.map +1 -1
- package/lib/state/file-system-state-store.d.ts +1 -0
- package/lib/state/file-system-state-store.d.ts.map +1 -1
- package/lib/state/file-system-state-store.js +16 -0
- package/lib/state/file-system-state-store.js.map +1 -1
- package/lib/state/operations.d.ts +1 -0
- package/lib/state/operations.d.ts.map +1 -1
- package/lib/state/operations.js +24 -0
- package/lib/state/operations.js.map +1 -1
- package/lib/state/proxy.d.ts +1 -0
- package/lib/state/proxy.d.ts.map +1 -1
- package/lib/state/proxy.js +3 -0
- package/lib/state/proxy.js.map +1 -1
- package/lib/state-cli.d.ts +9 -0
- package/lib/state-cli.d.ts.map +1 -0
- package/lib/state-cli.js +110 -0
- package/lib/state-cli.js.map +1 -0
- package/lib/state.d.ts +2 -0
- package/lib/state.d.ts.map +1 -1
- package/lib/stripe/price.d.ts.map +1 -1
- package/lib/stripe/price.js +13 -3
- package/lib/stripe/price.js.map +1 -1
- package/package.json +1 -1
- package/src/alchemy.ts +9 -0
- package/src/cloudflare/access-application.ts +489 -0
- package/src/cloudflare/access-group.ts +271 -0
- package/src/cloudflare/access-identity-provider.ts +584 -0
- package/src/cloudflare/access-policy.ts +392 -0
- package/src/cloudflare/access-rule.ts +307 -0
- package/src/cloudflare/access-service-token.ts +255 -0
- package/src/cloudflare/index.ts +6 -0
- package/src/cloudflare/miniflare/tunnel.ts +10 -5
- package/src/state/file-system-state-store.ts +16 -0
- package/src/state/operations.ts +22 -0
- package/src/state/proxy.ts +4 -0
- package/src/state-cli.ts +134 -0
- package/src/state.ts +2 -0
- package/src/stripe/price.ts +13 -3
- package/workers/cloudflare-state-store.js +17 -0
- package/workers/tunnel-proxy.js +3 -1
- package/workers/tunnel-proxy.ts +2 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import { Resource, ResourceKind } from "../resource.ts";
|
|
3
|
+
import { logger } from "../util/logger.ts";
|
|
4
|
+
import { serializeAccessRule, type AccessRule } from "./access-rule.ts";
|
|
5
|
+
import { isCloudflareApiError } from "./api-error.ts";
|
|
6
|
+
import {
|
|
7
|
+
extractCloudflareResult,
|
|
8
|
+
type CloudflareApiListResponse,
|
|
9
|
+
} from "./api-response.ts";
|
|
10
|
+
import {
|
|
11
|
+
createCloudflareApi,
|
|
12
|
+
type CloudflareApi,
|
|
13
|
+
type CloudflareApiOptions,
|
|
14
|
+
} from "./api.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Properties for creating or updating an {@link AccessGroup}.
|
|
18
|
+
*/
|
|
19
|
+
export interface AccessGroupProps extends CloudflareApiOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Display name of the group.
|
|
22
|
+
*
|
|
23
|
+
* @default ${app}-${stage}-${id}
|
|
24
|
+
*/
|
|
25
|
+
name?: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rules that grant membership (OR logic — any match includes the user).
|
|
29
|
+
*/
|
|
30
|
+
include?: AccessRule[];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Rules that revoke membership when matched.
|
|
34
|
+
*/
|
|
35
|
+
exclude?: AccessRule[];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rules that must additionally match for membership (AND logic).
|
|
39
|
+
*/
|
|
40
|
+
require?: AccessRule[];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mark this group as the account default. Default groups apply to every
|
|
44
|
+
* Access application unless explicitly overridden.
|
|
45
|
+
*
|
|
46
|
+
* @default false
|
|
47
|
+
*/
|
|
48
|
+
isDefault?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adopt an existing group with the same name instead of failing.
|
|
52
|
+
*
|
|
53
|
+
* @default false
|
|
54
|
+
*/
|
|
55
|
+
adopt?: boolean;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether to delete the group when removed from Alchemy.
|
|
59
|
+
*
|
|
60
|
+
* @default true
|
|
61
|
+
*/
|
|
62
|
+
delete?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Output for an {@link AccessGroup}.
|
|
67
|
+
*/
|
|
68
|
+
export type AccessGroup = Omit<AccessGroupProps, "adopt" | "delete"> & {
|
|
69
|
+
/** Cloudflare-assigned group UUID. */
|
|
70
|
+
id: string;
|
|
71
|
+
/** Display name. */
|
|
72
|
+
name: string;
|
|
73
|
+
/** ISO 8601 creation timestamp. */
|
|
74
|
+
createdAt: string;
|
|
75
|
+
/** ISO 8601 last-update timestamp. */
|
|
76
|
+
updatedAt: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Type guard for {@link AccessGroup}.
|
|
81
|
+
*/
|
|
82
|
+
export function isAccessGroup(resource: any): resource is AccessGroup {
|
|
83
|
+
return resource?.[ResourceKind] === "cloudflare::AccessGroup";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CloudflareAccessGroup {
|
|
87
|
+
id: string;
|
|
88
|
+
name: string;
|
|
89
|
+
include?: Record<string, unknown>[];
|
|
90
|
+
exclude?: Record<string, unknown>[];
|
|
91
|
+
require?: Record<string, unknown>[];
|
|
92
|
+
is_default?: boolean;
|
|
93
|
+
created_at: string;
|
|
94
|
+
updated_at: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Creates a Cloudflare Zero Trust [Access group](https://developers.cloudflare.com/cloudflare-one/identity/users/groups/),
|
|
99
|
+
* a reusable bundle of rules that can be referenced by Access policies.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* // Engineering team by email domain.
|
|
103
|
+
* const engineering = await AccessGroup("engineering", {
|
|
104
|
+
* name: "Engineering",
|
|
105
|
+
* include: [{ email_domain: { domain: "acme.com" } }],
|
|
106
|
+
* });
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // Allow a managed IP list, exclude one specific IP.
|
|
110
|
+
* const officeIps = await AccessGroup("office", {
|
|
111
|
+
* name: "Office IPs",
|
|
112
|
+
* include: [{ ip_list: { id: "<list-uuid>" } }],
|
|
113
|
+
* exclude: [{ ip: { ip: "203.0.113.99/32" } }],
|
|
114
|
+
* });
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* // Compose groups: admins are engineers who are also on-call. Resources
|
|
118
|
+
* // can be passed directly — Alchemy lifts `.id` at the wire boundary.
|
|
119
|
+
* const onCall = await AccessGroup("on-call", {
|
|
120
|
+
* include: [{ email_domain: { domain: "acme.com" } }],
|
|
121
|
+
* });
|
|
122
|
+
* const admins = await AccessGroup("admins", {
|
|
123
|
+
* include: [{ group: { id: engineering } }],
|
|
124
|
+
* require: [{ group: { id: onCall } }],
|
|
125
|
+
* });
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* // IdP-bound rules — match Okta groups via an AccessIdentityProvider.
|
|
129
|
+
* const okta = await AccessIdentityProvider("okta", {
|
|
130
|
+
* type: "okta",
|
|
131
|
+
* name: "Acme Okta",
|
|
132
|
+
* oktaAccount: "acme.okta.com",
|
|
133
|
+
* clientId: "...",
|
|
134
|
+
* clientSecret: alchemy.secret.env.OKTA_SECRET,
|
|
135
|
+
* });
|
|
136
|
+
* const sre = await AccessGroup("sre", {
|
|
137
|
+
* include: [{ okta: { name: "sre", identity_provider_id: okta } }],
|
|
138
|
+
* });
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* // Account default — applied implicitly to every Access application.
|
|
142
|
+
* await AccessGroup("default-deny", {
|
|
143
|
+
* isDefault: true,
|
|
144
|
+
* include: [{ everyone: {} }],
|
|
145
|
+
* exclude: [{ email_domain: { domain: "acme.com" } }],
|
|
146
|
+
* });
|
|
147
|
+
*/
|
|
148
|
+
export const AccessGroup = Resource(
|
|
149
|
+
"cloudflare::AccessGroup",
|
|
150
|
+
async function (
|
|
151
|
+
this: Context<AccessGroup>,
|
|
152
|
+
id: string,
|
|
153
|
+
props: AccessGroupProps,
|
|
154
|
+
): Promise<AccessGroup> {
|
|
155
|
+
const api = await createCloudflareApi(props);
|
|
156
|
+
const name = props.name ?? this.scope.createPhysicalName(id);
|
|
157
|
+
const basePath = `/accounts/${api.accountId}/access/groups`;
|
|
158
|
+
|
|
159
|
+
if (this.phase === "delete") {
|
|
160
|
+
if (this.output?.id && props.delete !== false) {
|
|
161
|
+
await deleteAccessGroup(api, this.output.id);
|
|
162
|
+
}
|
|
163
|
+
return this.destroy();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const body: Record<string, unknown> = {
|
|
167
|
+
name,
|
|
168
|
+
include: (props.include ?? []).map(serializeAccessRule),
|
|
169
|
+
exclude: (props.exclude ?? []).map(serializeAccessRule),
|
|
170
|
+
require: (props.require ?? []).map(serializeAccessRule),
|
|
171
|
+
is_default: props.isDefault ?? false,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
let group: CloudflareAccessGroup;
|
|
175
|
+
if (this.phase === "update" && this.output?.id) {
|
|
176
|
+
group = await extractCloudflareResult<CloudflareAccessGroup>(
|
|
177
|
+
`update access group "${name}"`,
|
|
178
|
+
api.put(`${basePath}/${this.output.id}`, body),
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
const adopt = props.adopt ?? this.scope.adopt;
|
|
182
|
+
try {
|
|
183
|
+
group = await extractCloudflareResult<CloudflareAccessGroup>(
|
|
184
|
+
`create access group "${name}"`,
|
|
185
|
+
api.post(basePath, body),
|
|
186
|
+
);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (adopt && isAccessDuplicateNameError(err)) {
|
|
189
|
+
const existing = await findAccessGroupByName(api, name);
|
|
190
|
+
if (!existing) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Access group "${name}" already exists but could not be found for adoption.`,
|
|
193
|
+
{ cause: err },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
logger.log(
|
|
197
|
+
`Adopting existing access group "${name}" (${existing.id})`,
|
|
198
|
+
);
|
|
199
|
+
group = await extractCloudflareResult<CloudflareAccessGroup>(
|
|
200
|
+
`adopt access group "${name}"`,
|
|
201
|
+
api.put(`${basePath}/${existing.id}`, body),
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
id: group.id,
|
|
211
|
+
name: group.name,
|
|
212
|
+
include: props.include,
|
|
213
|
+
exclude: props.exclude,
|
|
214
|
+
require: props.require,
|
|
215
|
+
isDefault: group.is_default,
|
|
216
|
+
createdAt: group.created_at,
|
|
217
|
+
updatedAt: group.updated_at,
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
function isAccessDuplicateNameError(err: unknown): boolean {
|
|
223
|
+
if (
|
|
224
|
+
isCloudflareApiError(err, { status: 409 }) ||
|
|
225
|
+
isCloudflareApiError(err, { status: 400 })
|
|
226
|
+
) {
|
|
227
|
+
const data = err.errorData;
|
|
228
|
+
return (
|
|
229
|
+
Array.isArray(data) &&
|
|
230
|
+
data.some(
|
|
231
|
+
(e) => "message" in e && /already exists/i.test(String(e.message)),
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function findAccessGroupByName(
|
|
239
|
+
api: CloudflareApi,
|
|
240
|
+
name: string,
|
|
241
|
+
): Promise<CloudflareAccessGroup | null> {
|
|
242
|
+
let page = 1;
|
|
243
|
+
const perPage = 50;
|
|
244
|
+
while (true) {
|
|
245
|
+
const response = await api.get(
|
|
246
|
+
`/accounts/${api.accountId}/access/groups?page=${page}&per_page=${perPage}`,
|
|
247
|
+
);
|
|
248
|
+
if (!response.ok) return null;
|
|
249
|
+
const data =
|
|
250
|
+
(await response.json()) as CloudflareApiListResponse<CloudflareAccessGroup>;
|
|
251
|
+
const match = data.result.find((g) => g.name === name);
|
|
252
|
+
if (match) return match;
|
|
253
|
+
const info = data.result_info;
|
|
254
|
+
if (!info || info.page * info.per_page >= info.total_count) return null;
|
|
255
|
+
page++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function deleteAccessGroup(
|
|
260
|
+
api: CloudflareApi,
|
|
261
|
+
groupId: string,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const response = await api.delete(
|
|
264
|
+
`/accounts/${api.accountId}/access/groups/${groupId}`,
|
|
265
|
+
);
|
|
266
|
+
if (!response.ok && response.status !== 404) {
|
|
267
|
+
logger.error(
|
|
268
|
+
`Error deleting access group ${groupId}: ${response.status} ${response.statusText}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|