alchemy 0.41.0 → 0.41.2
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/lib/build-date.d.ts +1 -1
- package/lib/build-date.js +1 -1
- package/lib/cloudflare/certificate-pack.d.ts +216 -0
- package/lib/cloudflare/certificate-pack.d.ts.map +1 -0
- package/lib/cloudflare/certificate-pack.js +350 -0
- package/lib/cloudflare/certificate-pack.js.map +1 -0
- package/lib/cloudflare/index.d.ts +1 -0
- package/lib/cloudflare/index.d.ts.map +1 -1
- package/lib/cloudflare/index.js +1 -0
- package/lib/cloudflare/index.js.map +1 -1
- package/lib/cloudflare/worker/miniflare.d.ts.map +1 -1
- package/lib/cloudflare/worker/miniflare.js +2 -1
- package/lib/cloudflare/worker/miniflare.js.map +1 -1
- package/lib/cloudflare/worker.d.ts +1 -1
- package/lib/util/find-open-port.d.ts +2 -0
- package/lib/util/find-open-port.d.ts.map +1 -0
- package/lib/util/find-open-port.js +24 -0
- package/lib/util/find-open-port.js.map +1 -0
- package/package.json +1 -1
- package/src/build-date.ts +1 -1
- package/src/cloudflare/certificate-pack.ts +698 -0
- package/src/cloudflare/index.ts +1 -0
- package/src/cloudflare/worker/miniflare.ts +2 -1
- package/src/util/find-open-port.ts +27 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import { Resource } from "../resource.ts";
|
|
3
|
+
import { logger } from "../util/logger.ts";
|
|
4
|
+
import { handleApiError } from "./api-error.ts";
|
|
5
|
+
import {
|
|
6
|
+
createCloudflareApi,
|
|
7
|
+
type CloudflareApi,
|
|
8
|
+
type CloudflareApiOptions,
|
|
9
|
+
} from "./api.ts";
|
|
10
|
+
import type { Zone } from "./zone.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Certificate Authority options for Advanced Certificate Packs
|
|
14
|
+
*/
|
|
15
|
+
export type CertificateAuthority = "google" | "lets_encrypt" | "ssl_com";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validation method for certificate verification
|
|
19
|
+
*/
|
|
20
|
+
export type ValidationMethod = "txt" | "http" | "email";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validity period options for certificates
|
|
24
|
+
*/
|
|
25
|
+
export type ValidityDays = 14 | 30 | 90 | 365;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Certificate pack status values during lifecycle
|
|
29
|
+
*/
|
|
30
|
+
export type CertificatePackStatus =
|
|
31
|
+
| "initializing"
|
|
32
|
+
| "pending_validation"
|
|
33
|
+
| "deleted"
|
|
34
|
+
| "pending_issuance"
|
|
35
|
+
| "pending_deployment"
|
|
36
|
+
| "pending_deletion"
|
|
37
|
+
| "pending_expiration"
|
|
38
|
+
| "expired"
|
|
39
|
+
| "active"
|
|
40
|
+
| "initializing_timed_out"
|
|
41
|
+
| "validation_timed_out"
|
|
42
|
+
| "issuance_timed_out"
|
|
43
|
+
| "deployment_timed_out"
|
|
44
|
+
| "deletion_timed_out"
|
|
45
|
+
| "pending_cleanup"
|
|
46
|
+
| "staging_deployment"
|
|
47
|
+
| "staging_active"
|
|
48
|
+
| "deactivating"
|
|
49
|
+
| "inactive"
|
|
50
|
+
| "backup_issued"
|
|
51
|
+
| "holding_deployment";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Properties for creating a Certificate Pack
|
|
55
|
+
*/
|
|
56
|
+
export interface CertificatePackProps extends CloudflareApiOptions {
|
|
57
|
+
/**
|
|
58
|
+
* The zone to create the certificate pack for
|
|
59
|
+
* Can be a Zone resource, zone ID string, or omitted to auto-infer from hosts
|
|
60
|
+
*/
|
|
61
|
+
zone?: string | Zone;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Certificate Authority to use for issuing the certificate
|
|
65
|
+
* - google: Google Trust Services (Enterprise features)
|
|
66
|
+
* - lets_encrypt: Let's Encrypt (Free, shorter validity periods)
|
|
67
|
+
* - ssl_com: SSL.com (Commercial certificates with extended validation)
|
|
68
|
+
*
|
|
69
|
+
* **Note:** This property is immutable after creation. To change the CA,
|
|
70
|
+
* you must delete and recreate the certificate pack.
|
|
71
|
+
*/
|
|
72
|
+
certificateAuthority: CertificateAuthority;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* List of hostnames to include in the certificate
|
|
76
|
+
* Maximum 50 hosts, must include the zone apex (root domain)
|
|
77
|
+
* Supports wildcards (e.g., "*.example.com")
|
|
78
|
+
*
|
|
79
|
+
* **Note:** This property is immutable after creation.
|
|
80
|
+
*/
|
|
81
|
+
hosts: string[];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Certificate type - only "advanced" is supported
|
|
85
|
+
*
|
|
86
|
+
* **Note:** This property is immutable after creation.
|
|
87
|
+
* @default "advanced"
|
|
88
|
+
*/
|
|
89
|
+
type?: "advanced";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Method used to validate domain ownership
|
|
93
|
+
* - txt: DNS TXT record validation
|
|
94
|
+
* - http: HTTP file validation
|
|
95
|
+
* - email: Email validation
|
|
96
|
+
*
|
|
97
|
+
* **Note:** This property is immutable after creation.
|
|
98
|
+
*/
|
|
99
|
+
validationMethod: ValidationMethod;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Certificate validity period in days
|
|
103
|
+
* Available options: 14, 30, 90, or 365 days
|
|
104
|
+
*
|
|
105
|
+
* **Note:** This property is immutable after creation.
|
|
106
|
+
*/
|
|
107
|
+
validityDays: ValidityDays;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether to add Cloudflare branding subdomain as Common Name
|
|
111
|
+
* Adds sni.cloudflaressl.com subdomain when enabled
|
|
112
|
+
*
|
|
113
|
+
* **Note:** This is the only property that can be updated after creation.
|
|
114
|
+
* @default false
|
|
115
|
+
*/
|
|
116
|
+
cloudflareBranding?: boolean;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether to delete the certificate pack
|
|
120
|
+
* If set to false, the pack will remain but the resource will be removed from state
|
|
121
|
+
*
|
|
122
|
+
* @default true
|
|
123
|
+
*/
|
|
124
|
+
delete?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Output returned after Certificate Pack creation/update
|
|
129
|
+
*/
|
|
130
|
+
export interface CertificatePack
|
|
131
|
+
extends Resource<"cloudflare::CertificatePack"> {
|
|
132
|
+
/**
|
|
133
|
+
* The unique ID of the certificate pack
|
|
134
|
+
*/
|
|
135
|
+
id: string;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Certificate Authority used for the certificate
|
|
139
|
+
*/
|
|
140
|
+
certificateAuthority: CertificateAuthority;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether Cloudflare branding is enabled
|
|
144
|
+
*/
|
|
145
|
+
cloudflareBranding: boolean;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* List of hostnames included in the certificate
|
|
149
|
+
*/
|
|
150
|
+
hosts: string[];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Current status of the certificate pack
|
|
154
|
+
*/
|
|
155
|
+
status: CertificatePackStatus;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Certificate type
|
|
159
|
+
*/
|
|
160
|
+
type: "advanced";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validation method used for domain verification
|
|
164
|
+
*/
|
|
165
|
+
validationMethod: ValidationMethod;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Certificate validity period in days
|
|
169
|
+
*/
|
|
170
|
+
validityDays: ValidityDays;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Zone ID the certificate pack belongs to
|
|
174
|
+
*/
|
|
175
|
+
zoneId: string;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Zone name (domain)
|
|
179
|
+
*/
|
|
180
|
+
zoneName: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Creates and manages Cloudflare Advanced Certificate Packs.
|
|
185
|
+
*
|
|
186
|
+
* Advanced Certificate Packs provide flexible SSL/TLS certificates with
|
|
187
|
+
* multiple Certificate Authority options, custom validity periods, and
|
|
188
|
+
* support for up to 50 hostnames per certificate.
|
|
189
|
+
*
|
|
190
|
+
* **Important Notes:**
|
|
191
|
+
* - Requires a paid Cloudflare plan (not available on Free plans)
|
|
192
|
+
* - Certificate provisioning can take up to 10 minutes
|
|
193
|
+
* - Most properties are immutable after creation (only cloudflareBranding can be updated)
|
|
194
|
+
* - To change immutable properties, you must delete and recreate the certificate pack
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* // Create a basic certificate pack with Let's Encrypt
|
|
198
|
+
* const basicCert = await CertificatePack("my-cert", {
|
|
199
|
+
* zone: myZone,
|
|
200
|
+
* certificateAuthority: "lets_encrypt",
|
|
201
|
+
* hosts: ["example.com", "www.example.com"],
|
|
202
|
+
* validationMethod: "txt",
|
|
203
|
+
* validityDays: 90
|
|
204
|
+
* });
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* // Create an enterprise certificate with Google Trust Services
|
|
208
|
+
* const enterpriseCert = await CertificatePack("enterprise-cert", {
|
|
209
|
+
* zone: "example.com",
|
|
210
|
+
* certificateAuthority: "google",
|
|
211
|
+
* hosts: ["example.com", "*.example.com", "api.example.com"],
|
|
212
|
+
* validationMethod: "txt",
|
|
213
|
+
* validityDays: 365,
|
|
214
|
+
* cloudflareBranding: true
|
|
215
|
+
* });
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* // Create a wildcard certificate with SSL.com
|
|
219
|
+
* const wildcardCert = await CertificatePack("wildcard-cert", {
|
|
220
|
+
* zone: myZone,
|
|
221
|
+
* certificateAuthority: "ssl_com",
|
|
222
|
+
* hosts: ["example.com", "*.example.com"],
|
|
223
|
+
* validationMethod: "email",
|
|
224
|
+
* validityDays: 365
|
|
225
|
+
* });
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* // Create a certificate for multiple subdomains
|
|
229
|
+
* const multiDomainCert = await CertificatePack("multi-cert", {
|
|
230
|
+
* zone: "example.com",
|
|
231
|
+
* certificateAuthority: "lets_encrypt",
|
|
232
|
+
* hosts: [
|
|
233
|
+
* "example.com",
|
|
234
|
+
* "www.example.com",
|
|
235
|
+
* "api.example.com",
|
|
236
|
+
* "admin.example.com",
|
|
237
|
+
* "blog.example.com"
|
|
238
|
+
* ],
|
|
239
|
+
* validationMethod: "http",
|
|
240
|
+
* validityDays: 90
|
|
241
|
+
* });
|
|
242
|
+
*
|
|
243
|
+
* @see https://developers.cloudflare.com/api/resources/ssl/subresources/certificate_packs/
|
|
244
|
+
*/
|
|
245
|
+
export const CertificatePack = Resource(
|
|
246
|
+
"cloudflare::CertificatePack",
|
|
247
|
+
async function (
|
|
248
|
+
this: Context<CertificatePack>,
|
|
249
|
+
_id: string,
|
|
250
|
+
props: CertificatePackProps,
|
|
251
|
+
): Promise<CertificatePack> {
|
|
252
|
+
// Create Cloudflare API client with automatic account discovery
|
|
253
|
+
const api = await createCloudflareApi(props);
|
|
254
|
+
|
|
255
|
+
// Resolve zone ID and zone name
|
|
256
|
+
let zoneId: string;
|
|
257
|
+
let zoneName: string;
|
|
258
|
+
|
|
259
|
+
if (props.zone) {
|
|
260
|
+
// Zone provided - use it
|
|
261
|
+
if (typeof props.zone === "string") {
|
|
262
|
+
zoneId = props.zone;
|
|
263
|
+
// Try to get zone name from API for better error messages
|
|
264
|
+
try {
|
|
265
|
+
const zoneResponse = await api.get(`/zones/${zoneId}`);
|
|
266
|
+
if (zoneResponse.ok) {
|
|
267
|
+
const zoneData = (await zoneResponse.json()) as {
|
|
268
|
+
result: { name: string };
|
|
269
|
+
};
|
|
270
|
+
zoneName = zoneData.result.name;
|
|
271
|
+
} else {
|
|
272
|
+
zoneName = zoneId; // Fallback to ID
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
zoneName = zoneId; // Fallback to ID
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
zoneId = props.zone.id;
|
|
279
|
+
zoneName = props.zone.name || props.zone.id;
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
// Auto-infer zone from the first host
|
|
283
|
+
if (props.hosts.length === 0) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
"At least one host must be specified when zone is not provided",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
logger.log(`Auto-inferring zone from hostname: ${props.hosts[0]}`);
|
|
290
|
+
const zoneInfo = await findZoneForHostname(api, props.hosts[0]);
|
|
291
|
+
zoneId = zoneInfo.zoneId;
|
|
292
|
+
zoneName = zoneInfo.zoneName;
|
|
293
|
+
logger.log(`Auto-inferred zone: ${zoneName} (${zoneId})`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (this.phase === "delete") {
|
|
297
|
+
if (this.output?.id && props.delete !== false) {
|
|
298
|
+
const deleteResponse = await api.delete(
|
|
299
|
+
`/zones/${zoneId}/ssl/certificate_packs/${this.output.id}`,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (!deleteResponse.ok && deleteResponse.status !== 404) {
|
|
303
|
+
await handleApiError(
|
|
304
|
+
deleteResponse,
|
|
305
|
+
"delete",
|
|
306
|
+
"certificate pack",
|
|
307
|
+
this.output.id,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
logger.warn("Certificate pack not found, skipping delete");
|
|
312
|
+
}
|
|
313
|
+
return this.destroy();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (this.phase === "update" && this.output?.id) {
|
|
317
|
+
// Validate immutable properties
|
|
318
|
+
const currentPack = this.output;
|
|
319
|
+
|
|
320
|
+
if (props.certificateAuthority !== currentPack.certificateAuthority) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Cannot change certificateAuthority from '${currentPack.certificateAuthority}' to '${props.certificateAuthority}'. Certificate Authority is immutable after creation. You must delete and recreate the certificate pack.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (
|
|
327
|
+
JSON.stringify(props.hosts.sort()) !==
|
|
328
|
+
JSON.stringify(currentPack.hosts.sort())
|
|
329
|
+
) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`Cannot change hosts from [${currentPack.hosts.join(", ")}] to [${props.hosts.join(", ")}]. Hosts are immutable after creation. You must delete and recreate the certificate pack.`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (props.validationMethod !== currentPack.validationMethod) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`Cannot change validationMethod from '${currentPack.validationMethod}' to '${props.validationMethod}'. Validation method is immutable after creation. You must delete and recreate the certificate pack.`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (props.validityDays !== currentPack.validityDays) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Cannot change validityDays from ${currentPack.validityDays} to ${props.validityDays}. Validity period is immutable after creation. You must delete and recreate the certificate pack.`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const type = props.type || "advanced";
|
|
348
|
+
if (type !== currentPack.type) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Cannot change type from '${currentPack.type}' to '${type}'. Type is immutable after creation. You must delete and recreate the certificate pack.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Only cloudflareBranding can be updated
|
|
355
|
+
if (props.cloudflareBranding !== currentPack.cloudflareBranding) {
|
|
356
|
+
logger.log(
|
|
357
|
+
`Updating certificate pack cloudflare branding from ${currentPack.cloudflareBranding} to ${props.cloudflareBranding}`,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const updateResponse = await api.patch(
|
|
361
|
+
`/zones/${zoneId}/ssl/certificate_packs/${this.output.id}`,
|
|
362
|
+
{
|
|
363
|
+
cloudflare_branding: props.cloudflareBranding || false,
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
if (!updateResponse.ok) {
|
|
368
|
+
await handleApiError(
|
|
369
|
+
updateResponse,
|
|
370
|
+
"update",
|
|
371
|
+
"certificate pack",
|
|
372
|
+
this.output.id,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Get updated certificate pack details
|
|
378
|
+
const response = await api.get(
|
|
379
|
+
`/zones/${zoneId}/ssl/certificate_packs/${this.output.id}`,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
if (!response.ok) {
|
|
383
|
+
await handleApiError(
|
|
384
|
+
response,
|
|
385
|
+
"get",
|
|
386
|
+
"certificate pack",
|
|
387
|
+
this.output.id,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const updatedPack = (
|
|
392
|
+
(await response.json()) as { result: CloudflareCertificatePack }
|
|
393
|
+
).result;
|
|
394
|
+
|
|
395
|
+
return this({
|
|
396
|
+
id: updatedPack.id,
|
|
397
|
+
certificateAuthority: updatedPack.certificate_authority,
|
|
398
|
+
cloudflareBranding: updatedPack.cloudflare_branding,
|
|
399
|
+
hosts: updatedPack.hosts,
|
|
400
|
+
status: updatedPack.status,
|
|
401
|
+
type: updatedPack.type,
|
|
402
|
+
validationMethod: updatedPack.validation_method,
|
|
403
|
+
validityDays: updatedPack.validity_days,
|
|
404
|
+
zoneId: updatedPack.zone_id,
|
|
405
|
+
zoneName: zoneName,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Create new certificate pack
|
|
410
|
+
if (props.hosts.length === 0) {
|
|
411
|
+
throw new Error("At least one host must be specified");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (props.hosts.length > 50) {
|
|
415
|
+
throw new Error("Maximum 50 hosts are allowed per certificate pack");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Validate that zone apex is included
|
|
419
|
+
const hasZoneApex = props.hosts.some(
|
|
420
|
+
(host) => host === zoneName || (zoneName && host === zoneName),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
if (!hasZoneApex && zoneName) {
|
|
424
|
+
logger.warn(
|
|
425
|
+
`Zone apex '${zoneName}' is not included in hosts. This may cause certificate validation issues.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Check for existing certificate pack that matches our configuration
|
|
430
|
+
const existingPack = await findMatchingCertificatePack(api, zoneId, props);
|
|
431
|
+
|
|
432
|
+
if (existingPack) {
|
|
433
|
+
// Adopt the existing certificate pack
|
|
434
|
+
logger.log(
|
|
435
|
+
`Adopting existing certificate pack ${existingPack.id} instead of creating a new one`,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
return this({
|
|
439
|
+
id: existingPack.id,
|
|
440
|
+
certificateAuthority: existingPack.certificate_authority,
|
|
441
|
+
cloudflareBranding: existingPack.cloudflare_branding,
|
|
442
|
+
hosts: existingPack.hosts,
|
|
443
|
+
status: existingPack.status,
|
|
444
|
+
type: existingPack.type,
|
|
445
|
+
validationMethod: existingPack.validation_method,
|
|
446
|
+
validityDays: existingPack.validity_days,
|
|
447
|
+
zoneId: existingPack.zone_id,
|
|
448
|
+
zoneName: zoneName,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
logger.log(
|
|
453
|
+
`Creating certificate pack with ${props.hosts.length} hosts using ${props.certificateAuthority}`,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const createResponse = await api.post(
|
|
457
|
+
`/zones/${zoneId}/ssl/certificate_packs/order`,
|
|
458
|
+
{
|
|
459
|
+
certificate_authority: props.certificateAuthority,
|
|
460
|
+
cloudflare_branding: props.cloudflareBranding || false,
|
|
461
|
+
hosts: props.hosts,
|
|
462
|
+
type: props.type || "advanced",
|
|
463
|
+
validation_method: props.validationMethod,
|
|
464
|
+
validity_days: props.validityDays,
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
if (!createResponse.ok) {
|
|
469
|
+
const errorText = await createResponse.text();
|
|
470
|
+
|
|
471
|
+
// Provide helpful error messages for common issues
|
|
472
|
+
if (errorText.includes("subscription")) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`Failed to create certificate pack: Advanced Certificate Packs require a paid Cloudflare plan. Please upgrade your subscription to use this feature.\n\nOriginal error: ${errorText}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (errorText.includes("quota")) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`Failed to create certificate pack: Certificate pack quota exceeded. Please check your account limits.\n\nOriginal error: ${errorText}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Throw generic error for other cases
|
|
485
|
+
throw new Error(
|
|
486
|
+
`Failed to create certificate pack: ${createResponse.statusText}\n\n${errorText}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const createdPack = (
|
|
491
|
+
(await createResponse.json()) as { result: CloudflareCertificatePack }
|
|
492
|
+
).result;
|
|
493
|
+
|
|
494
|
+
logger.log(
|
|
495
|
+
`Certificate pack created with ID ${createdPack.id}. Status: ${createdPack.status}. Note: Certificate provisioning can take up to 10 minutes.`,
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
return this({
|
|
499
|
+
id: createdPack.id,
|
|
500
|
+
certificateAuthority: createdPack.certificate_authority,
|
|
501
|
+
cloudflareBranding: createdPack.cloudflare_branding,
|
|
502
|
+
hosts: createdPack.hosts,
|
|
503
|
+
status: createdPack.status,
|
|
504
|
+
type: createdPack.type,
|
|
505
|
+
validationMethod: createdPack.validation_method,
|
|
506
|
+
validityDays: createdPack.validity_days,
|
|
507
|
+
zoneId: createdPack.zone_id,
|
|
508
|
+
zoneName: zoneName,
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Cloudflare Certificate Pack API response format
|
|
515
|
+
*/
|
|
516
|
+
interface CloudflareCertificatePack {
|
|
517
|
+
id: string;
|
|
518
|
+
certificate_authority: CertificateAuthority;
|
|
519
|
+
cloudflare_branding: boolean;
|
|
520
|
+
hosts: string[];
|
|
521
|
+
status: CertificatePackStatus;
|
|
522
|
+
type: "advanced";
|
|
523
|
+
validation_method: ValidationMethod;
|
|
524
|
+
validity_days: ValidityDays;
|
|
525
|
+
zone_id: string;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Helper function to wait for certificate pack to reach active status
|
|
530
|
+
* Useful for testing or when you need to ensure the certificate is ready
|
|
531
|
+
*
|
|
532
|
+
* @param api CloudflareApi instance
|
|
533
|
+
* @param zoneId Zone ID
|
|
534
|
+
* @param certificatePackId Certificate pack ID
|
|
535
|
+
* @param timeoutMs Maximum time to wait in milliseconds (default: 15 minutes)
|
|
536
|
+
* @returns Promise resolving to the final certificate pack status
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* // Wait for certificate to become active
|
|
540
|
+
* const finalStatus = await waitForCertificatePackActive(
|
|
541
|
+
* api,
|
|
542
|
+
* zoneId,
|
|
543
|
+
* certificatePack.id,
|
|
544
|
+
* 10 * 60 * 1000 // 10 minutes
|
|
545
|
+
* );
|
|
546
|
+
* console.log(`Certificate pack is now: ${finalStatus}`);
|
|
547
|
+
*/
|
|
548
|
+
export async function waitForCertificatePackActive(
|
|
549
|
+
api: CloudflareApi,
|
|
550
|
+
zoneId: string,
|
|
551
|
+
certificatePackId: string,
|
|
552
|
+
timeoutMs: number = 15 * 60 * 1000, // 15 minutes default
|
|
553
|
+
): Promise<CertificatePackStatus> {
|
|
554
|
+
const startTime = Date.now();
|
|
555
|
+
const pollInterval = 30 * 1000; // Poll every 30 seconds
|
|
556
|
+
|
|
557
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
558
|
+
const response = await api.get(
|
|
559
|
+
`/zones/${zoneId}/ssl/certificate_packs/${certificatePackId}`,
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
if (!response.ok) {
|
|
563
|
+
throw new Error(
|
|
564
|
+
`Failed to check certificate pack status: ${response.statusText}`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const pack = (
|
|
569
|
+
(await response.json()) as { result: CloudflareCertificatePack }
|
|
570
|
+
).result;
|
|
571
|
+
|
|
572
|
+
// Return immediately if active or in a final error state
|
|
573
|
+
if (pack.status === "active") {
|
|
574
|
+
return pack.status;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (
|
|
578
|
+
pack.status.includes("timed_out") ||
|
|
579
|
+
pack.status === "expired" ||
|
|
580
|
+
pack.status === "deleted"
|
|
581
|
+
) {
|
|
582
|
+
return pack.status;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Wait before next poll
|
|
586
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
throw new Error(
|
|
590
|
+
`Certificate pack did not become active within ${timeoutMs / 1000 / 60} minutes`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Helper function to find zone ID from a hostname
|
|
596
|
+
* Searches for the zone that matches the hostname or its parent domains
|
|
597
|
+
*
|
|
598
|
+
* @param api CloudflareApi instance
|
|
599
|
+
* @param hostname The hostname to find the zone for
|
|
600
|
+
* @returns Promise resolving to the zone ID and zone name
|
|
601
|
+
*/
|
|
602
|
+
async function findZoneForHostname(
|
|
603
|
+
api: CloudflareApi,
|
|
604
|
+
hostname: string,
|
|
605
|
+
): Promise<{ zoneId: string; zoneName: string }> {
|
|
606
|
+
// Remove wildcard prefix if present
|
|
607
|
+
const cleanHostname = hostname.replace(/^\*\./, "");
|
|
608
|
+
|
|
609
|
+
// Get all zones and find the best match
|
|
610
|
+
const response = await api.get("/zones");
|
|
611
|
+
|
|
612
|
+
if (!response.ok) {
|
|
613
|
+
throw new Error(`Failed to list zones: ${response.statusText}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const zonesData = (await response.json()) as {
|
|
617
|
+
result: Array<{ id: string; name: string }>;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Find the zone that best matches the hostname
|
|
621
|
+
// We look for the longest matching zone name (most specific)
|
|
622
|
+
let bestMatch: { zoneId: string; zoneName: string } | null = null;
|
|
623
|
+
let longestMatch = 0;
|
|
624
|
+
|
|
625
|
+
for (const zone of zonesData.result) {
|
|
626
|
+
if (
|
|
627
|
+
cleanHostname === zone.name ||
|
|
628
|
+
cleanHostname.endsWith(`.${zone.name}`)
|
|
629
|
+
) {
|
|
630
|
+
if (zone.name.length > longestMatch) {
|
|
631
|
+
longestMatch = zone.name.length;
|
|
632
|
+
bestMatch = { zoneId: zone.id, zoneName: zone.name };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!bestMatch) {
|
|
638
|
+
throw new Error(
|
|
639
|
+
`Could not find zone for hostname '${hostname}'. Available zones: ${zonesData.result.map((z) => z.name).join(", ")}`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return bestMatch;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Helper function to find existing certificate packs that match the given configuration
|
|
648
|
+
* Used to adopt existing certificates instead of creating duplicates
|
|
649
|
+
*
|
|
650
|
+
* @param api CloudflareApi instance
|
|
651
|
+
* @param zoneId Zone ID to search in
|
|
652
|
+
* @param props Certificate pack properties to match
|
|
653
|
+
* @returns Promise resolving to matching certificate pack or null if none found
|
|
654
|
+
*/
|
|
655
|
+
async function findMatchingCertificatePack(
|
|
656
|
+
api: CloudflareApi,
|
|
657
|
+
zoneId: string,
|
|
658
|
+
props: CertificatePackProps,
|
|
659
|
+
): Promise<CloudflareCertificatePack | null> {
|
|
660
|
+
const response = await api.get(`/zones/${zoneId}/ssl/certificate_packs`);
|
|
661
|
+
|
|
662
|
+
if (!response.ok) {
|
|
663
|
+
throw new Error(`Failed to list certificate packs: ${response.statusText}`);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const packsData = (await response.json()) as {
|
|
667
|
+
result: CloudflareCertificatePack[];
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// Find a certificate pack that matches our configuration
|
|
671
|
+
for (const pack of packsData.result) {
|
|
672
|
+
// Skip deleted or expired packs
|
|
673
|
+
if (pack.status === "deleted" || pack.status === "expired") {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Check if the configuration matches
|
|
678
|
+
if (
|
|
679
|
+
pack.certificate_authority === props.certificateAuthority &&
|
|
680
|
+
pack.validation_method === props.validationMethod &&
|
|
681
|
+
pack.validity_days === props.validityDays &&
|
|
682
|
+
pack.type === (props.type || "advanced")
|
|
683
|
+
) {
|
|
684
|
+
// Check if all requested hosts are covered by this certificate pack
|
|
685
|
+
const packHosts = new Set(pack.hosts);
|
|
686
|
+
const allHostsCovered = props.hosts.every((host) => packHosts.has(host));
|
|
687
|
+
|
|
688
|
+
if (allHostsCovered) {
|
|
689
|
+
logger.log(
|
|
690
|
+
`Found existing certificate pack ${pack.id} that covers all requested hosts`,
|
|
691
|
+
);
|
|
692
|
+
return pack;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return null;
|
|
698
|
+
}
|
package/src/cloudflare/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./browser-rendering.ts";
|
|
|
13
13
|
export * from "./bucket.ts";
|
|
14
14
|
export * from "./bundle/external.ts";
|
|
15
15
|
export * from "./bundle/local-dev-cloudflare-shim.ts";
|
|
16
|
+
export * from "./certificate-pack.ts";
|
|
16
17
|
export * from "./container.ts";
|
|
17
18
|
export * from "./custom-domain.ts";
|
|
18
19
|
export * from "./d1-clone.ts";
|