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,584 @@
1
+ import type { Context } from "../context.ts";
2
+ import { Resource, ResourceKind } from "../resource.ts";
3
+ import { Secret } from "../secret.ts";
4
+ import { logger } from "../util/logger.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
+ * Supported Access identity provider types. The five most common providers
18
+ * have strict {@link AccessIdentityProviderProps} variants below; everything
19
+ * else falls back to {@link OtherIdentityProviderProps} with a permissive
20
+ * `config` shape.
21
+ */
22
+ export type AccessIdentityProviderType =
23
+ | "onetimepin"
24
+ | "google"
25
+ | "google-apps"
26
+ | "github"
27
+ | "okta"
28
+ | "azureAD"
29
+ | "oidc"
30
+ | "saml"
31
+ | "centrify"
32
+ | "facebook"
33
+ | "linkedin"
34
+ | "onelogin"
35
+ | "pingone"
36
+ | "yandex"
37
+ | (string & {});
38
+
39
+ interface BaseAccessIdpProps extends CloudflareApiOptions {
40
+ /**
41
+ * Display name shown on the Access login page.
42
+ *
43
+ * @default ${app}-${stage}-${id}
44
+ */
45
+ name?: string;
46
+
47
+ /**
48
+ * Adopt an existing IdP with the same name instead of failing.
49
+ *
50
+ * @default false
51
+ */
52
+ adopt?: boolean;
53
+
54
+ /**
55
+ * Whether to delete the IdP when removed from Alchemy.
56
+ *
57
+ * @default true
58
+ */
59
+ delete?: boolean;
60
+ }
61
+
62
+ /**
63
+ * One-Time PIN — Cloudflare emails a code to the user. No external IdP
64
+ * configuration required.
65
+ */
66
+ export interface OneTimePinIdentityProviderProps extends BaseAccessIdpProps {
67
+ type: "onetimepin";
68
+ }
69
+
70
+ /**
71
+ * Google OAuth identity provider.
72
+ */
73
+ export interface GoogleIdentityProviderProps extends BaseAccessIdpProps {
74
+ type: "google";
75
+
76
+ /**
77
+ * OAuth 2.0 client ID issued by Google for your application.
78
+ * This is a public identifier (not a secret).
79
+ */
80
+ clientId: string;
81
+
82
+ /**
83
+ * OAuth 2.0 client secret issued by Google. Use {@link alchemy.secret} so
84
+ * the value is encrypted at rest in the Alchemy state file.
85
+ */
86
+ clientSecret: string | Secret;
87
+
88
+ /**
89
+ * Custom claims to request from the IdP and forward into the Access JWT.
90
+ */
91
+ claims?: string[];
92
+
93
+ /**
94
+ * Override the OIDC claim Cloudflare reads as the user's email
95
+ * (defaults to `email`).
96
+ */
97
+ emailClaimName?: string;
98
+ }
99
+
100
+ /**
101
+ * Okta identity provider (OIDC under the hood).
102
+ */
103
+ export interface OktaIdentityProviderProps extends BaseAccessIdpProps {
104
+ type: "okta";
105
+
106
+ /**
107
+ * Your Okta tenant subdomain, e.g. `acme` for `acme.okta.com`.
108
+ */
109
+ oktaAccount: string;
110
+
111
+ /**
112
+ * Custom Okta authorization server ID. Omit to use Okta's default
113
+ * authorization server.
114
+ */
115
+ authorizationServerId?: string;
116
+
117
+ /**
118
+ * OAuth 2.0 client ID of the Okta app integration.
119
+ */
120
+ clientId: string;
121
+
122
+ /**
123
+ * OAuth 2.0 client secret of the Okta app integration. Use
124
+ * {@link alchemy.secret} for at-rest encryption.
125
+ */
126
+ clientSecret: string | Secret;
127
+
128
+ /**
129
+ * Custom claims to request from Okta and forward into the Access JWT.
130
+ */
131
+ claims?: string[];
132
+
133
+ /**
134
+ * Override the OIDC claim Cloudflare reads as the user's email
135
+ * (defaults to `email`).
136
+ */
137
+ emailClaimName?: string;
138
+ }
139
+
140
+ /**
141
+ * Generic OpenID Connect identity provider.
142
+ */
143
+ export interface OidcIdentityProviderProps extends BaseAccessIdpProps {
144
+ type: "oidc";
145
+
146
+ /**
147
+ * IdP authorization endpoint URL (the page users are redirected to to
148
+ * sign in).
149
+ */
150
+ authUrl: string;
151
+
152
+ /**
153
+ * IdP token endpoint URL (used by Cloudflare to exchange the auth code
154
+ * for tokens).
155
+ */
156
+ tokenUrl: string;
157
+
158
+ /**
159
+ * JWKS endpoint URL — public keys Cloudflare uses to verify ID-token
160
+ * signatures.
161
+ */
162
+ certsUrl: string;
163
+
164
+ /**
165
+ * OAuth 2.0 client ID registered with the IdP.
166
+ */
167
+ clientId: string;
168
+
169
+ /**
170
+ * OAuth 2.0 client secret registered with the IdP. Use
171
+ * {@link alchemy.secret} for at-rest encryption.
172
+ */
173
+ clientSecret: string | Secret;
174
+
175
+ /**
176
+ * OIDC scopes to request. Defaults to `["openid", "email", "profile"]`
177
+ * server-side if omitted.
178
+ */
179
+ scopes?: string[];
180
+
181
+ /**
182
+ * Custom claims to request from the IdP and forward into the Access JWT.
183
+ */
184
+ claims?: string[];
185
+
186
+ /**
187
+ * Override the OIDC claim Cloudflare reads as the user's email
188
+ * (defaults to `email`).
189
+ */
190
+ emailClaimName?: string;
191
+
192
+ /**
193
+ * Enable PKCE on the authorization code flow. Recommended for public
194
+ * clients and required by some IdPs.
195
+ */
196
+ pkceEnabled?: boolean;
197
+ }
198
+
199
+ /**
200
+ * Generic SAML 2.0 identity provider.
201
+ */
202
+ export interface SamlIdentityProviderProps extends BaseAccessIdpProps {
203
+ type: "saml";
204
+
205
+ /**
206
+ * SAML issuer (entity ID) of the IdP, used to validate the `Issuer`
207
+ * element of incoming assertions.
208
+ */
209
+ issuerUrl: string;
210
+
211
+ /**
212
+ * IdP single sign-on URL — Cloudflare redirects users here to start
213
+ * the SAML flow.
214
+ */
215
+ ssoTargetUrl: string;
216
+
217
+ /**
218
+ * PEM-encoded x509 certificates the IdP will use to sign assertions.
219
+ * Multiple entries support certificate rotation.
220
+ */
221
+ idpPublicCerts: string[];
222
+
223
+ /**
224
+ * SAML attributes to forward from the assertion into the Access JWT.
225
+ */
226
+ attributes?: string[];
227
+
228
+ /**
229
+ * Override the SAML attribute Cloudflare reads as the user's email
230
+ * (defaults to `email`).
231
+ */
232
+ emailAttributeName?: string;
233
+
234
+ /**
235
+ * Map SAML attributes to HTTP headers Cloudflare will inject when
236
+ * forwarding requests to the origin.
237
+ */
238
+ headerAttributes?: { headerName: string; attributeName: string }[];
239
+
240
+ /**
241
+ * Sign outgoing AuthnRequests with Cloudflare's signing key.
242
+ */
243
+ signRequest?: boolean;
244
+ }
245
+
246
+ /**
247
+ * Catch-all for IdP types not covered by a strict variant
248
+ * (`azureAD`, `github`, `google-apps`, `centrify`, `facebook`, `linkedin`,
249
+ * `onelogin`, `pingone`, `yandex`, or future providers).
250
+ *
251
+ * Pass a free-form camelCase `config` object — keys are converted to
252
+ * snake_case at the API boundary. This nested escape hatch is an
253
+ * intentional exception to the flat-props convention used by the strict
254
+ * variants above.
255
+ */
256
+ export interface OtherIdentityProviderProps extends BaseAccessIdpProps {
257
+ type: Exclude<
258
+ AccessIdentityProviderType,
259
+ "onetimepin" | "google" | "okta" | "oidc" | "saml"
260
+ >;
261
+
262
+ /**
263
+ * Free-form provider configuration. Use {@link alchemy.secret} for any
264
+ * sensitive values; they are unwrapped before sending to Cloudflare.
265
+ */
266
+ config: { clientId?: string; clientSecret?: string | Secret } & Record<
267
+ string,
268
+ unknown
269
+ >;
270
+ }
271
+
272
+ /**
273
+ * Properties for creating or updating an {@link AccessIdentityProvider}.
274
+ */
275
+ export type AccessIdentityProviderProps =
276
+ | OneTimePinIdentityProviderProps
277
+ | GoogleIdentityProviderProps
278
+ | OktaIdentityProviderProps
279
+ | OidcIdentityProviderProps
280
+ | SamlIdentityProviderProps
281
+ | OtherIdentityProviderProps;
282
+
283
+ /**
284
+ * Output for an {@link AccessIdentityProvider}.
285
+ */
286
+ export type AccessIdentityProvider = Omit<
287
+ AccessIdentityProviderProps,
288
+ "adopt" | "delete"
289
+ > & {
290
+ /** Cloudflare-assigned IdP UUID. */
291
+ id: string;
292
+ /** Display name. */
293
+ name: string;
294
+ };
295
+
296
+ /**
297
+ * Type guard for {@link AccessIdentityProvider}.
298
+ */
299
+ export function isAccessIdentityProvider(
300
+ resource: any,
301
+ ): resource is AccessIdentityProvider {
302
+ return resource?.[ResourceKind] === "cloudflare::AccessIdentityProvider";
303
+ }
304
+
305
+ interface CloudflareAccessIdentityProvider {
306
+ id: string;
307
+ name: string;
308
+ type: string;
309
+ config?: Record<string, unknown>;
310
+ }
311
+
312
+ /**
313
+ * Creates a Cloudflare Zero Trust [Access identity provider](https://developers.cloudflare.com/cloudflare-one/identity/idp-integration/)
314
+ * which lets users sign in to Access-protected applications.
315
+ *
316
+ * @example
317
+ * // Built-in One-Time PIN (no IdP setup required).
318
+ * const otp = await AccessIdentityProvider("otp", {
319
+ * type: "onetimepin",
320
+ * name: "Email OTP",
321
+ * });
322
+ *
323
+ * @example
324
+ * // Google OAuth. clientId is a public OAuth identifier (not a secret).
325
+ * const google = await AccessIdentityProvider("google", {
326
+ * type: "google",
327
+ * name: "Google",
328
+ * clientId: process.env.GOOGLE_CLIENT_ID!,
329
+ * clientSecret: alchemy.secret.env.GOOGLE_CLIENT_SECRET,
330
+ * });
331
+ *
332
+ * @example
333
+ * // Generic OIDC provider.
334
+ * const oidc = await AccessIdentityProvider("idp", {
335
+ * type: "oidc",
336
+ * name: "Corporate IdP",
337
+ * authUrl: "https://idp.example.com/oauth2/authorize",
338
+ * tokenUrl: "https://idp.example.com/oauth2/token",
339
+ * certsUrl: "https://idp.example.com/oauth2/certs",
340
+ * clientId: "my-app",
341
+ * clientSecret: alchemy.secret.env.IDP_CLIENT_SECRET,
342
+ * scopes: ["openid", "email", "profile"],
343
+ * pkceEnabled: true,
344
+ * });
345
+ */
346
+ export const AccessIdentityProvider = Resource(
347
+ "cloudflare::AccessIdentityProvider",
348
+ async function (
349
+ this: Context<AccessIdentityProvider>,
350
+ id: string,
351
+ props: AccessIdentityProviderProps,
352
+ ): Promise<AccessIdentityProvider> {
353
+ const api = await createCloudflareApi(props);
354
+ const name = props.name ?? this.scope.createPhysicalName(id);
355
+ const basePath = `/accounts/${api.accountId}/access/identity_providers`;
356
+
357
+ if (this.phase === "delete") {
358
+ if (this.output?.id && props.delete !== false) {
359
+ await deleteAccessIdentityProvider(api, this.output.id);
360
+ }
361
+ return this.destroy();
362
+ }
363
+
364
+ // type is immutable — recreate if it changed.
365
+ if (
366
+ this.phase === "update" &&
367
+ this.output &&
368
+ this.output.type !== props.type
369
+ ) {
370
+ this.replace(true);
371
+ }
372
+
373
+ // Cloudflare requires `config` to always be present, even for variants
374
+ // that don't take any (e.g. `onetimepin`). Omitting it returns
375
+ // [12130] "unexpected end of JSON input".
376
+ const body: Record<string, unknown> = {
377
+ name,
378
+ type: props.type,
379
+ config: extractIdpConfig(props),
380
+ };
381
+
382
+ let result: CloudflareAccessIdentityProvider;
383
+ if (this.phase === "update" && this.output?.id) {
384
+ result = await extractCloudflareResult<CloudflareAccessIdentityProvider>(
385
+ `update access identity provider "${name}"`,
386
+ api.put(`${basePath}/${this.output.id}`, body),
387
+ );
388
+ } else {
389
+ const adopt = props.adopt ?? this.scope.adopt;
390
+ try {
391
+ result =
392
+ await extractCloudflareResult<CloudflareAccessIdentityProvider>(
393
+ `create access identity provider "${name}"`,
394
+ api.post(basePath, body),
395
+ );
396
+ } catch (err) {
397
+ if (adopt && isAccessDuplicateNameError(err)) {
398
+ const existing = await findAccessIdentityProviderByName(api, name);
399
+ if (!existing) {
400
+ throw new Error(
401
+ `Identity provider "${name}" already exists but could not be found for adoption.`,
402
+ { cause: err },
403
+ );
404
+ }
405
+ logger.log(
406
+ `Adopting existing access identity provider "${name}" (${existing.id})`,
407
+ );
408
+ result =
409
+ await extractCloudflareResult<CloudflareAccessIdentityProvider>(
410
+ `adopt access identity provider "${name}"`,
411
+ api.put(`${basePath}/${existing.id}`, body),
412
+ );
413
+ } else {
414
+ throw err;
415
+ }
416
+ }
417
+ }
418
+
419
+ const rest: Record<string, unknown> = { ...props };
420
+ delete rest.adopt;
421
+ delete rest.delete;
422
+
423
+ // Output convention (CLAUDE.md): secrets are always wrapped. Strict
424
+ // variants carry `clientSecret` at the top level; the `Other` variant
425
+ // tucks it inside `config`.
426
+ if (typeof rest.clientSecret === "string") {
427
+ rest.clientSecret = Secret.wrap(rest.clientSecret);
428
+ }
429
+ const config = rest.config as Record<string, unknown> | undefined;
430
+ if (config && typeof config.clientSecret === "string") {
431
+ rest.config = {
432
+ ...config,
433
+ clientSecret: Secret.wrap(config.clientSecret),
434
+ };
435
+ }
436
+
437
+ return {
438
+ ...rest,
439
+ id: result.id,
440
+ name: result.name,
441
+ } as AccessIdentityProvider;
442
+ },
443
+ );
444
+
445
+ /**
446
+ * Top-level prop keys that are *not* part of the IdP-specific configuration
447
+ * (the wire `config` blob) — Alchemy/Cloudflare metadata, the type
448
+ * discriminator, and the explicit `config` escape hatch on the `Other`
449
+ * variant.
450
+ */
451
+ const IDP_METADATA_KEYS = new Set<string>([
452
+ "name",
453
+ "type",
454
+ "adopt",
455
+ "delete",
456
+ "baseUrl",
457
+ "profile",
458
+ "apiKey",
459
+ "apiToken",
460
+ "accountId",
461
+ "email",
462
+ "config",
463
+ ]);
464
+
465
+ /**
466
+ * Build the wire-format `config` blob from props. Strict variants store
467
+ * config fields flat at the top level; the `Other` variant uses an explicit
468
+ * nested `config` object as an escape hatch.
469
+ */
470
+ function extractIdpConfig(
471
+ props: AccessIdentityProviderProps,
472
+ ): Record<string, unknown> {
473
+ if ("config" in props && props.config) {
474
+ return camelToSnakeWithSecrets(props.config as Record<string, unknown>);
475
+ }
476
+ const flat: Record<string, unknown> = {};
477
+ for (const [key, value] of Object.entries(
478
+ props as unknown as Record<string, unknown>,
479
+ )) {
480
+ if (!IDP_METADATA_KEYS.has(key) && value !== undefined) {
481
+ flat[key] = value;
482
+ }
483
+ }
484
+ return camelToSnakeWithSecrets(flat);
485
+ }
486
+
487
+ /**
488
+ * Convert a camelCase config object to snake_case for the wire, unwrapping
489
+ * any {@link Secret} values along the way. Recurses into arrays of objects
490
+ * (e.g. SAML `headerAttributes`).
491
+ */
492
+ function camelToSnakeWithSecrets(
493
+ input: Record<string, unknown>,
494
+ ): Record<string, unknown> {
495
+ const out: Record<string, unknown> = {};
496
+ for (const [key, value] of Object.entries(input)) {
497
+ if (value === undefined) continue;
498
+ const snakeKey = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
499
+ out[snakeKey] = transformValue(value);
500
+ }
501
+ return out;
502
+ }
503
+
504
+ function transformValue(value: unknown): unknown {
505
+ if (value instanceof Secret) return value.unencrypted;
506
+ if (Array.isArray(value)) {
507
+ return value.map((item) =>
508
+ item && typeof item === "object" && !Array.isArray(item)
509
+ ? camelToSnakeWithSecrets(item as Record<string, unknown>)
510
+ : transformValue(item),
511
+ );
512
+ }
513
+ return value;
514
+ }
515
+
516
+ /**
517
+ * Cloudflare returns 409/400 with an "already exists" message for duplicate
518
+ * IdP names.
519
+ */
520
+ function isAccessDuplicateNameError(err: unknown): boolean {
521
+ if (
522
+ isCloudflareApiError(err, { status: 409 }) ||
523
+ isCloudflareApiError(err, { status: 400 })
524
+ ) {
525
+ const data = err.errorData;
526
+ return (
527
+ Array.isArray(data) &&
528
+ data.some(
529
+ (e) => "message" in e && /already exists/i.test(String(e.message)),
530
+ )
531
+ );
532
+ }
533
+ return false;
534
+ }
535
+
536
+ /**
537
+ * Look up an existing IdP by name across paginated results.
538
+ */
539
+ async function findAccessIdentityProviderByName(
540
+ api: CloudflareApi,
541
+ name: string,
542
+ ): Promise<CloudflareAccessIdentityProvider | null> {
543
+ let page = 1;
544
+ const perPage = 50;
545
+ while (true) {
546
+ const response = await api.get(
547
+ `/accounts/${api.accountId}/access/identity_providers?page=${page}&per_page=${perPage}`,
548
+ );
549
+ if (!response.ok) return null;
550
+ const data =
551
+ (await response.json()) as CloudflareApiListResponse<CloudflareAccessIdentityProvider>;
552
+ const match = data.result.find((p) => p.name === name);
553
+ if (match) return match;
554
+ const info = data.result_info;
555
+ if (!info || info.page * info.per_page >= info.total_count) return null;
556
+ page++;
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Delete an IdP. Cloudflare returns 400 if any Application references it.
562
+ */
563
+ async function deleteAccessIdentityProvider(
564
+ api: CloudflareApi,
565
+ idpId: string,
566
+ ): Promise<void> {
567
+ const response = await api.delete(
568
+ `/accounts/${api.accountId}/access/identity_providers/${idpId}`,
569
+ );
570
+ if (!response.ok && response.status !== 404) {
571
+ let body = "";
572
+ try {
573
+ body = await response.text();
574
+ } catch {}
575
+ if (/in use|reference|associated/i.test(body)) {
576
+ throw new Error(
577
+ `Cannot delete identity provider ${idpId}: it is referenced by one or more Access applications. Remove those references first.\n${body}`,
578
+ );
579
+ }
580
+ logger.error(
581
+ `Error deleting access identity provider ${idpId}: ${response.status} ${response.statusText}\n${body}`,
582
+ );
583
+ }
584
+ }