alchemy 0.93.4 → 0.93.7

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 (73) hide show
  1. package/bin/alchemy.js +1966 -1825
  2. package/bin/alchemy.ts +2 -0
  3. package/bin/commands/state.ts +58 -0
  4. package/bin/services/execute-alchemy.ts +9 -1
  5. package/lib/alchemy.d.ts.map +1 -1
  6. package/lib/alchemy.js +7 -0
  7. package/lib/alchemy.js.map +1 -1
  8. package/lib/cloudflare/access-application.d.ts +204 -0
  9. package/lib/cloudflare/access-application.d.ts.map +1 -0
  10. package/lib/cloudflare/access-application.js +205 -0
  11. package/lib/cloudflare/access-application.js.map +1 -0
  12. package/lib/cloudflare/access-group.d.ts +115 -0
  13. package/lib/cloudflare/access-group.d.ts.map +1 -0
  14. package/lib/cloudflare/access-group.js +147 -0
  15. package/lib/cloudflare/access-group.js.map +1 -0
  16. package/lib/cloudflare/access-identity-provider.d.ts +261 -0
  17. package/lib/cloudflare/access-identity-provider.d.ts.map +1 -0
  18. package/lib/cloudflare/access-identity-provider.js +229 -0
  19. package/lib/cloudflare/access-identity-provider.js.map +1 -0
  20. package/lib/cloudflare/access-policy.d.ts +168 -0
  21. package/lib/cloudflare/access-policy.d.ts.map +1 -0
  22. package/lib/cloudflare/access-policy.js +176 -0
  23. package/lib/cloudflare/access-policy.js.map +1 -0
  24. package/lib/cloudflare/access-rule.d.ts +248 -0
  25. package/lib/cloudflare/access-rule.d.ts.map +1 -0
  26. package/lib/cloudflare/access-rule.js +37 -0
  27. package/lib/cloudflare/access-rule.js.map +1 -0
  28. package/lib/cloudflare/access-service-token.d.ts +86 -0
  29. package/lib/cloudflare/access-service-token.d.ts.map +1 -0
  30. package/lib/cloudflare/access-service-token.js +127 -0
  31. package/lib/cloudflare/access-service-token.js.map +1 -0
  32. package/lib/cloudflare/index.d.ts +6 -0
  33. package/lib/cloudflare/index.d.ts.map +1 -1
  34. package/lib/cloudflare/index.js +6 -0
  35. package/lib/cloudflare/index.js.map +1 -1
  36. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
  37. package/lib/cloudflare/miniflare/build-worker-options.js +17 -3
  38. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
  39. package/lib/state/file-system-state-store.d.ts +1 -0
  40. package/lib/state/file-system-state-store.d.ts.map +1 -1
  41. package/lib/state/file-system-state-store.js +16 -0
  42. package/lib/state/file-system-state-store.js.map +1 -1
  43. package/lib/state/operations.d.ts +1 -0
  44. package/lib/state/operations.d.ts.map +1 -1
  45. package/lib/state/operations.js +24 -0
  46. package/lib/state/operations.js.map +1 -1
  47. package/lib/state/proxy.d.ts +1 -0
  48. package/lib/state/proxy.d.ts.map +1 -1
  49. package/lib/state/proxy.js +3 -0
  50. package/lib/state/proxy.js.map +1 -1
  51. package/lib/state-cli.d.ts +9 -0
  52. package/lib/state-cli.d.ts.map +1 -0
  53. package/lib/state-cli.js +110 -0
  54. package/lib/state-cli.js.map +1 -0
  55. package/lib/state.d.ts +2 -0
  56. package/lib/state.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/alchemy.ts +9 -0
  59. package/src/cloudflare/access-application.ts +489 -0
  60. package/src/cloudflare/access-group.ts +271 -0
  61. package/src/cloudflare/access-identity-provider.ts +584 -0
  62. package/src/cloudflare/access-policy.ts +392 -0
  63. package/src/cloudflare/access-rule.ts +307 -0
  64. package/src/cloudflare/access-service-token.ts +255 -0
  65. package/src/cloudflare/index.ts +6 -0
  66. package/src/cloudflare/miniflare/build-worker-options.ts +20 -3
  67. package/src/state/file-system-state-store.ts +16 -0
  68. package/src/state/operations.ts +22 -0
  69. package/src/state/proxy.ts +4 -0
  70. package/src/state-cli.ts +134 -0
  71. package/src/state.ts +2 -0
  72. package/workers/cloudflare-state-store.js +17 -0
  73. package/workers/tunnel-proxy.js +1 -1
@@ -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
+ }