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,392 @@
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
+ * Access decision a policy applies when its rules match.
18
+ * - `allow`: grant access when an authenticated user matches `include`.
19
+ * - `deny`: deny access when matched.
20
+ * - `non_identity`: allow without identity (e.g. service tokens, IP allowlists).
21
+ * - `bypass`: skip Access entirely (no auth required).
22
+ */
23
+ export type AccessPolicyDecision = "allow" | "deny" | "non_identity" | "bypass";
24
+
25
+ /**
26
+ * An approval group authorising an access request when
27
+ * `approvalRequired: true`.
28
+ */
29
+ export interface AccessPolicyApprovalGroup {
30
+ /** Number of approvals required from this group. */
31
+ approvalsNeeded: number;
32
+ /** Email addresses of approvers. */
33
+ emailAddresses?: string[];
34
+ /** UUID of an Access email list. */
35
+ emailListUuid?: string;
36
+ }
37
+
38
+ /**
39
+ * Optional MFA enforcement on top of the IdP's authentication.
40
+ */
41
+ export interface AccessPolicyMfaConfig {
42
+ allowedAuthenticators?: string[];
43
+ mfaDisabled?: boolean;
44
+ /** Cloudflare duration string, e.g. `"30m"`. */
45
+ sessionDuration?: string;
46
+ }
47
+
48
+ /**
49
+ * Connection-protocol-specific options (currently only RDP clipboard
50
+ * formats are supported by Cloudflare).
51
+ */
52
+ export interface AccessPolicyConnectionRules {
53
+ rdp?: {
54
+ allowedClipboardFormats?: ("text" | "image" | "files")[];
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Properties for creating or updating an {@link AccessPolicy}.
60
+ */
61
+ export interface AccessPolicyProps extends CloudflareApiOptions {
62
+ /**
63
+ * Display name of the policy.
64
+ *
65
+ * @default ${app}-${stage}-${id}
66
+ */
67
+ name?: string;
68
+
69
+ /**
70
+ * Decision the policy applies. **Immutable** — changing the decision will
71
+ * trigger replacement of the underlying Cloudflare resource.
72
+ */
73
+ decision: AccessPolicyDecision;
74
+
75
+ /**
76
+ * Rules a request must match to be considered (OR logic). Must be
77
+ * non-empty — Cloudflare rejects policies with no include rules.
78
+ */
79
+ include: AccessRule[];
80
+
81
+ /**
82
+ * Rules that, when matched, exclude the request from this policy.
83
+ */
84
+ exclude?: AccessRule[];
85
+
86
+ /**
87
+ * Rules that must additionally match (AND logic).
88
+ */
89
+ require?: AccessRule[];
90
+
91
+ /**
92
+ * Require explicit approval before granting access.
93
+ */
94
+ approvalRequired?: boolean;
95
+
96
+ /**
97
+ * Approver groups consulted when `approvalRequired: true`.
98
+ */
99
+ approvalGroups?: AccessPolicyApprovalGroup[];
100
+
101
+ /**
102
+ * Prompt the user for a purpose justification on each access.
103
+ */
104
+ purposeJustificationRequired?: boolean;
105
+
106
+ /**
107
+ * Prompt text shown when `purposeJustificationRequired` is true.
108
+ */
109
+ purposeJustificationPrompt?: string;
110
+
111
+ /**
112
+ * Force isolated browser rendering for this policy.
113
+ */
114
+ isolationRequired?: boolean;
115
+
116
+ /**
117
+ * Optional MFA enforcement.
118
+ */
119
+ mfaConfig?: AccessPolicyMfaConfig;
120
+
121
+ /**
122
+ * Override the default Access session duration (Cloudflare duration string).
123
+ */
124
+ sessionDuration?: string;
125
+
126
+ /**
127
+ * Per-protocol connection rules (e.g. RDP clipboard restrictions).
128
+ */
129
+ connectionRules?: AccessPolicyConnectionRules;
130
+
131
+ /**
132
+ * Adopt an existing policy with the same name instead of failing.
133
+ *
134
+ * @default false
135
+ */
136
+ adopt?: boolean;
137
+
138
+ /**
139
+ * Whether to delete the policy when removed from Alchemy.
140
+ *
141
+ * @default true
142
+ */
143
+ delete?: boolean;
144
+ }
145
+
146
+ /**
147
+ * Output for an {@link AccessPolicy}.
148
+ */
149
+ export type AccessPolicy = Omit<AccessPolicyProps, "adopt" | "delete"> & {
150
+ /** Cloudflare-assigned policy UUID. */
151
+ id: string;
152
+ /** Display name. */
153
+ name: string;
154
+ /** Number of applications currently referencing this policy. */
155
+ appCount: number;
156
+ /** ISO 8601 creation timestamp. */
157
+ createdAt: string;
158
+ /** ISO 8601 last-update timestamp. */
159
+ updatedAt: string;
160
+ };
161
+
162
+ /**
163
+ * Type guard for {@link AccessPolicy}.
164
+ */
165
+ export function isAccessPolicy(resource: any): resource is AccessPolicy {
166
+ return resource?.[ResourceKind] === "cloudflare::AccessPolicy";
167
+ }
168
+
169
+ interface CloudflareAccessPolicy {
170
+ id: string;
171
+ name: string;
172
+ decision: AccessPolicyDecision;
173
+ include: Record<string, unknown>[];
174
+ exclude?: Record<string, unknown>[];
175
+ require?: Record<string, unknown>[];
176
+ app_count?: number;
177
+ reusable?: boolean;
178
+ created_at: string;
179
+ updated_at: string;
180
+ }
181
+
182
+ /**
183
+ * Creates a reusable Cloudflare Zero Trust [Access policy](https://developers.cloudflare.com/cloudflare-one/policies/access/)
184
+ * that can be attached to one or more {@link AccessApplication} resources.
185
+ *
186
+ * @example
187
+ * // Allow employees from a specific email domain.
188
+ * const employees = await AccessPolicy("employees", {
189
+ * name: "Employees",
190
+ * decision: "allow",
191
+ * include: [{ email_domain: { domain: "acme.com" } }],
192
+ * });
193
+ *
194
+ * @example
195
+ * // Bypass Access for an office IP range.
196
+ * const officeBypass = await AccessPolicy("office-bypass", {
197
+ * name: "Office Bypass",
198
+ * decision: "bypass",
199
+ * include: [{ ip: { ip: "203.0.113.0/24" } }],
200
+ * });
201
+ *
202
+ * @example
203
+ * // Reference an AccessGroup and require approval.
204
+ * const sensitive = await AccessPolicy("sensitive", {
205
+ * name: "Sensitive admin access",
206
+ * decision: "allow",
207
+ * include: [{ group: { id: adminGroup } }],
208
+ * approvalRequired: true,
209
+ * approvalGroups: [
210
+ * { approvalsNeeded: 2, emailAddresses: ["security@acme.com"] },
211
+ * ],
212
+ * isolationRequired: true,
213
+ * });
214
+ */
215
+ export const AccessPolicy = Resource(
216
+ "cloudflare::AccessPolicy",
217
+ async function (
218
+ this: Context<AccessPolicy>,
219
+ id: string,
220
+ props: AccessPolicyProps,
221
+ ): Promise<AccessPolicy> {
222
+ const api = await createCloudflareApi(props);
223
+ const name = props.name ?? this.scope.createPhysicalName(id);
224
+ const basePath = `/accounts/${api.accountId}/access/policies`;
225
+
226
+ if (this.phase === "delete") {
227
+ if (this.output?.id && props.delete !== false) {
228
+ await deleteAccessPolicy(api, this.output.id);
229
+ }
230
+ return this.destroy();
231
+ }
232
+
233
+ if (!props.include || props.include.length === 0) {
234
+ throw new Error(
235
+ `AccessPolicy "${name}" requires at least one rule in 'include'.`,
236
+ );
237
+ }
238
+
239
+ // decision is immutable — recreate if it changed.
240
+ if (
241
+ this.phase === "update" &&
242
+ this.output &&
243
+ this.output.decision !== props.decision
244
+ ) {
245
+ this.replace(true);
246
+ }
247
+
248
+ const body: Record<string, unknown> = {
249
+ name,
250
+ decision: props.decision,
251
+ reusable: true,
252
+ include: props.include.map(serializeAccessRule),
253
+ exclude: (props.exclude ?? []).map(serializeAccessRule),
254
+ require: (props.require ?? []).map(serializeAccessRule),
255
+ };
256
+ if (props.approvalRequired !== undefined)
257
+ body.approval_required = props.approvalRequired;
258
+ if (props.approvalGroups)
259
+ body.approval_groups = props.approvalGroups.map((g) => ({
260
+ approvals_needed: g.approvalsNeeded,
261
+ email_addresses: g.emailAddresses,
262
+ email_list_uuid: g.emailListUuid,
263
+ }));
264
+ if (props.purposeJustificationRequired !== undefined)
265
+ body.purpose_justification_required = props.purposeJustificationRequired;
266
+ if (props.purposeJustificationPrompt !== undefined)
267
+ body.purpose_justification_prompt = props.purposeJustificationPrompt;
268
+ if (props.isolationRequired !== undefined)
269
+ body.isolation_required = props.isolationRequired;
270
+ if (props.sessionDuration !== undefined)
271
+ body.session_duration = props.sessionDuration;
272
+ if (props.mfaConfig)
273
+ body.mfa_config = {
274
+ allowed_authenticators: props.mfaConfig.allowedAuthenticators,
275
+ mfa_disabled: props.mfaConfig.mfaDisabled,
276
+ session_duration: props.mfaConfig.sessionDuration,
277
+ };
278
+ if (props.connectionRules?.rdp)
279
+ body.connection_rules = {
280
+ rdp: {
281
+ allowed_clipboard_formats:
282
+ props.connectionRules.rdp.allowedClipboardFormats,
283
+ },
284
+ };
285
+
286
+ let policy: CloudflareAccessPolicy;
287
+ if (this.phase === "update" && this.output?.id) {
288
+ policy = await extractCloudflareResult<CloudflareAccessPolicy>(
289
+ `update access policy "${name}"`,
290
+ api.put(`${basePath}/${this.output.id}`, body),
291
+ );
292
+ } else {
293
+ const adopt = props.adopt ?? this.scope.adopt;
294
+ try {
295
+ policy = await extractCloudflareResult<CloudflareAccessPolicy>(
296
+ `create access policy "${name}"`,
297
+ api.post(basePath, body),
298
+ );
299
+ } catch (err) {
300
+ if (adopt && isAccessDuplicateNameError(err)) {
301
+ const existing = await findAccessPolicyByName(api, name);
302
+ if (!existing) {
303
+ throw new Error(
304
+ `Access policy "${name}" already exists but could not be found for adoption.`,
305
+ { cause: err },
306
+ );
307
+ }
308
+ logger.log(
309
+ `Adopting existing access policy "${name}" (${existing.id})`,
310
+ );
311
+ policy = await extractCloudflareResult<CloudflareAccessPolicy>(
312
+ `adopt access policy "${name}"`,
313
+ api.put(`${basePath}/${existing.id}`, body),
314
+ );
315
+ } else {
316
+ throw err;
317
+ }
318
+ }
319
+ }
320
+
321
+ return {
322
+ id: policy.id,
323
+ name: policy.name,
324
+ decision: policy.decision,
325
+ include: props.include,
326
+ exclude: props.exclude,
327
+ require: props.require,
328
+ approvalRequired: props.approvalRequired,
329
+ approvalGroups: props.approvalGroups,
330
+ purposeJustificationRequired: props.purposeJustificationRequired,
331
+ purposeJustificationPrompt: props.purposeJustificationPrompt,
332
+ isolationRequired: props.isolationRequired,
333
+ mfaConfig: props.mfaConfig,
334
+ sessionDuration: props.sessionDuration,
335
+ connectionRules: props.connectionRules,
336
+ appCount: policy.app_count ?? 0,
337
+ createdAt: policy.created_at,
338
+ updatedAt: policy.updated_at,
339
+ };
340
+ },
341
+ );
342
+
343
+ function isAccessDuplicateNameError(err: unknown): boolean {
344
+ if (
345
+ isCloudflareApiError(err, { status: 409 }) ||
346
+ isCloudflareApiError(err, { status: 400 })
347
+ ) {
348
+ const data = err.errorData;
349
+ return (
350
+ Array.isArray(data) &&
351
+ data.some(
352
+ (e) => "message" in e && /already exists/i.test(String(e.message)),
353
+ )
354
+ );
355
+ }
356
+ return false;
357
+ }
358
+
359
+ async function findAccessPolicyByName(
360
+ api: CloudflareApi,
361
+ name: string,
362
+ ): Promise<CloudflareAccessPolicy | null> {
363
+ let page = 1;
364
+ const perPage = 50;
365
+ while (true) {
366
+ const response = await api.get(
367
+ `/accounts/${api.accountId}/access/policies?page=${page}&per_page=${perPage}`,
368
+ );
369
+ if (!response.ok) return null;
370
+ const data =
371
+ (await response.json()) as CloudflareApiListResponse<CloudflareAccessPolicy>;
372
+ const match = data.result.find((p) => p.name === name);
373
+ if (match) return match;
374
+ const info = data.result_info;
375
+ if (!info || info.page * info.per_page >= info.total_count) return null;
376
+ page++;
377
+ }
378
+ }
379
+
380
+ async function deleteAccessPolicy(
381
+ api: CloudflareApi,
382
+ policyId: string,
383
+ ): Promise<void> {
384
+ const response = await api.delete(
385
+ `/accounts/${api.accountId}/access/policies/${policyId}`,
386
+ );
387
+ if (!response.ok && response.status !== 404) {
388
+ logger.error(
389
+ `Error deleting access policy ${policyId}: ${response.status} ${response.statusText}`,
390
+ );
391
+ }
392
+ }
@@ -0,0 +1,307 @@
1
+ import { ResourceKind } from "../resource.ts";
2
+ import type { AccessGroup } from "./access-group.ts";
3
+ import type { AccessIdentityProvider } from "./access-identity-provider.ts";
4
+ import type { AccessServiceToken } from "./access-service-token.ts";
5
+
6
+ /**
7
+ * Match anyone with this email address.
8
+ */
9
+ export interface EmailAccessRule {
10
+ email: { email: string };
11
+ }
12
+
13
+ /**
14
+ * Match anyone with an email address ending in this domain.
15
+ */
16
+ export interface EmailDomainAccessRule {
17
+ email_domain: { domain: string };
18
+ }
19
+
20
+ /**
21
+ * Match a single IP address (IPv4 or IPv6) or CIDR.
22
+ */
23
+ export interface IpAccessRule {
24
+ ip: { ip: string };
25
+ }
26
+
27
+ /**
28
+ * Match an IP from a managed Cloudflare IP list.
29
+ */
30
+ export interface IpListAccessRule {
31
+ ip_list: { id: string };
32
+ }
33
+
34
+ /**
35
+ * Match every user. Rarely useful in `include` — typical in `exclude` or as a fallback.
36
+ */
37
+ export interface EveryoneAccessRule {
38
+ everyone: Record<string, never>;
39
+ }
40
+
41
+ /**
42
+ * Match a member of the referenced Access group.
43
+ */
44
+ export interface GroupAccessRule {
45
+ group: { id: string | AccessGroup };
46
+ }
47
+
48
+ /**
49
+ * Match requests authenticating with a specific service token.
50
+ */
51
+ export interface ServiceTokenAccessRule {
52
+ service_token: { token_id: string | AccessServiceToken };
53
+ }
54
+
55
+ /**
56
+ * Match any valid service token in the account.
57
+ */
58
+ export interface AnyValidServiceTokenAccessRule {
59
+ any_valid_service_token: Record<string, never>;
60
+ }
61
+
62
+ /**
63
+ * Match an Azure AD group by object ID.
64
+ */
65
+ export interface AzureGroupAccessRule {
66
+ azure: {
67
+ id: string;
68
+ identity_provider_id: string | AccessIdentityProvider;
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Match an Okta group by name.
74
+ */
75
+ export interface OktaGroupAccessRule {
76
+ okta: {
77
+ name: string;
78
+ identity_provider_id: string | AccessIdentityProvider;
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Match a SAML attribute returned by the IdP.
84
+ */
85
+ export interface SamlAttributeAccessRule {
86
+ saml: {
87
+ attribute_name: string;
88
+ attribute_value: string;
89
+ identity_provider_id: string | AccessIdentityProvider;
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Match a Google Workspace group by email.
95
+ */
96
+ export interface GsuiteGroupAccessRule {
97
+ gsuite: {
98
+ email: string;
99
+ identity_provider_id: string | AccessIdentityProvider;
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Match a member of a GitHub organization or team.
105
+ */
106
+ export interface GithubOrganizationAccessRule {
107
+ github_organization: {
108
+ name: string;
109
+ team?: string;
110
+ identity_provider_id: string | AccessIdentityProvider;
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Match any client presenting a valid mTLS certificate.
116
+ */
117
+ export interface CertificateAccessRule {
118
+ certificate: Record<string, never>;
119
+ }
120
+
121
+ /**
122
+ * Match an mTLS certificate with the given Common Name.
123
+ */
124
+ export interface CommonNameAccessRule {
125
+ common_name: { common_name: string };
126
+ }
127
+
128
+ /**
129
+ * Match a device that satisfies a configured posture check.
130
+ */
131
+ export interface DevicePostureAccessRule {
132
+ device_posture: { integration_uid: string };
133
+ }
134
+
135
+ /**
136
+ * Match by authentication method (e.g., `mfa`, `pwd`).
137
+ */
138
+ export interface AuthMethodAccessRule {
139
+ auth_method: { auth_method: string };
140
+ }
141
+
142
+ /**
143
+ * Match users who logged in via a specific identity provider.
144
+ */
145
+ export interface LoginMethodAccessRule {
146
+ login_method: { id: string | AccessIdentityProvider };
147
+ }
148
+
149
+ /**
150
+ * Match an authentication context (e.g., Azure AD CA).
151
+ */
152
+ export interface AuthenticationContextAccessRule {
153
+ authentication_context: {
154
+ id: string;
155
+ ac_id: string;
156
+ identity_provider_id: string | AccessIdentityProvider;
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Match an OIDC claim returned by the IdP.
162
+ */
163
+ export interface OidcClaimAccessRule {
164
+ oidc_claim: {
165
+ name: string;
166
+ value: string;
167
+ identity_provider_id: string | AccessIdentityProvider;
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Match users at or above the given Cloudflare user-risk score.
173
+ */
174
+ export interface UserRiskScoreAccessRule {
175
+ user_risk_score: {
176
+ provider: string;
177
+ risk_level: "low" | "medium" | "high";
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Delegate evaluation to an external endpoint.
183
+ */
184
+ export interface ExternalEvaluationAccessRule {
185
+ external_evaluation: { evaluate_url: string; keys_url: string };
186
+ }
187
+
188
+ /**
189
+ * Match by ISO 3166 country code (legacy alias of `geo`).
190
+ */
191
+ export interface CountryAccessRule {
192
+ country: { country_code: string };
193
+ }
194
+
195
+ /**
196
+ * Match by ISO 3166 country code.
197
+ */
198
+ export interface GeoAccessRule {
199
+ geo: { country_code: string };
200
+ }
201
+
202
+ /**
203
+ * Match a hostname/domain.
204
+ */
205
+ export interface DomainAccessRule {
206
+ domain: { domain: string };
207
+ }
208
+
209
+ /**
210
+ * Match a token issued by a linked Access application.
211
+ */
212
+ export interface LinkedAppTokenAccessRule {
213
+ linked_app_token: { app_uid: string };
214
+ }
215
+
216
+ /**
217
+ * Match an SSO authentication context (Azure AD CA equivalent).
218
+ */
219
+ export interface AccessAuthContextAccessRule {
220
+ access_auth_context: {
221
+ id: string;
222
+ ac_id: string;
223
+ identity_provider_id: string | AccessIdentityProvider;
224
+ };
225
+ }
226
+
227
+ /**
228
+ * A single Access rule expression, used in `include`, `exclude`, and `require`
229
+ * arrays of {@link AccessPolicy} and {@link AccessGroup}.
230
+ *
231
+ * Each rule is a single-key object whose key is the rule type and whose value
232
+ * is the rule-specific configuration. Mirrors Cloudflare's wire format so
233
+ * expressions can be copy-pasted directly from the Cloudflare docs.
234
+ *
235
+ * @see https://developers.cloudflare.com/cloudflare-one/policies/access/
236
+ */
237
+ export type AccessRule =
238
+ | EmailAccessRule
239
+ | EmailDomainAccessRule
240
+ | IpAccessRule
241
+ | IpListAccessRule
242
+ | EveryoneAccessRule
243
+ | GroupAccessRule
244
+ | ServiceTokenAccessRule
245
+ | AnyValidServiceTokenAccessRule
246
+ | AzureGroupAccessRule
247
+ | OktaGroupAccessRule
248
+ | SamlAttributeAccessRule
249
+ | GsuiteGroupAccessRule
250
+ | GithubOrganizationAccessRule
251
+ | CertificateAccessRule
252
+ | CommonNameAccessRule
253
+ | DevicePostureAccessRule
254
+ | AuthMethodAccessRule
255
+ | LoginMethodAccessRule
256
+ | AuthenticationContextAccessRule
257
+ | OidcClaimAccessRule
258
+ | UserRiskScoreAccessRule
259
+ | ExternalEvaluationAccessRule
260
+ | CountryAccessRule
261
+ | GeoAccessRule
262
+ | DomainAccessRule
263
+ | LinkedAppTokenAccessRule
264
+ | AccessAuthContextAccessRule;
265
+
266
+ /**
267
+ * Serialize an {@link AccessRule} to its wire JSON shape, replacing any lifted
268
+ * Resource references (`group.id`, `service_token.token_id`,
269
+ * `*.identity_provider_id`, `login_method.id`) with the resource's `id`.
270
+ *
271
+ * @internal
272
+ */
273
+ export function serializeAccessRule(rule: AccessRule): Record<string, unknown> {
274
+ const entries = Object.entries(rule);
275
+ if (entries.length !== 1) {
276
+ throw new Error(
277
+ `Invalid AccessRule: expected exactly one key, got ${entries.length}`,
278
+ );
279
+ }
280
+ const [key, value] = entries[0];
281
+ if (typeof value !== "object" || value === null) {
282
+ return { [key]: value };
283
+ }
284
+ const serialized: Record<string, unknown> = {};
285
+ for (const [field, fieldValue] of Object.entries(
286
+ value as Record<string, unknown>,
287
+ )) {
288
+ serialized[field] = isResourceRef(fieldValue)
289
+ ? (fieldValue as { id: string }).id
290
+ : fieldValue;
291
+ }
292
+ return { [key]: serialized };
293
+ }
294
+
295
+ /**
296
+ * True for Alchemy Resource objects (carry the {@link ResourceKind} symbol),
297
+ * false for plain literal values — including future rule shapes that may nest
298
+ * literal config objects.
299
+ */
300
+ function isResourceRef(value: unknown): value is { id: string } {
301
+ return (
302
+ !!value &&
303
+ typeof value === "object" &&
304
+ !Array.isArray(value) &&
305
+ (value as Record<symbol, unknown>)[ResourceKind] !== undefined
306
+ );
307
+ }