import { AcmeCrypto } from './acme.classes.crypto.js'; import { AcmeError } from './acme.classes.error.js'; import type { AcmeHttpClient } from './acme.classes.http-client.js'; import type { IAcmeAuthorization, IAcmeIdentifier, IAcmeOrder, } from './acme.interfaces.js'; /** * ACME order lifecycle management. * Handles order creation, authorization retrieval, finalization, and certificate download. */ export class AcmeOrderManager { private httpClient: AcmeHttpClient; constructor(httpClient: AcmeHttpClient) { this.httpClient = httpClient; } /** * Create a new ACME order for the given identifiers */ async create(opts: { identifiers: IAcmeIdentifier[] }): Promise { const dir = await this.httpClient.getDirectory(); const response = await this.httpClient.signedRequest(dir.newOrder, { identifiers: opts.identifiers, }); const order = response.data as IAcmeOrder; // Capture order URL from Location header order.url = response.headers['location'] || ''; return order; } /** * Retrieve all authorizations for an order (POST-as-GET each authorization URL) */ async getAuthorizations(order: IAcmeOrder): Promise { const authorizations: IAcmeAuthorization[] = []; for (const authzUrl of order.authorizations) { const response = await this.httpClient.signedRequest(authzUrl, null); authorizations.push(response.data as IAcmeAuthorization); } return authorizations; } /** * Finalize an order by submitting the CSR. * Waits for the order to reach 'valid' status. * Mutates the order object with updated status and certificate URL. */ async finalize(order: IAcmeOrder, csrPem: string): Promise { // Convert PEM CSR to base64url DER for ACME const csrDer = AcmeCrypto.pemToBuffer(csrPem); const csrB64url = csrDer.toString('base64url'); const response = await this.httpClient.signedRequest(order.finalize, { csr: csrB64url }); // Update order with response data const updatedOrder = response.data; order.status = updatedOrder.status; if (updatedOrder.certificate) { order.certificate = updatedOrder.certificate; } // If not yet valid, poll until it is if (order.status !== 'valid' && order.url) { const finalOrder = await this.waitForValidStatus({ url: order.url }); order.status = finalOrder.status; order.certificate = finalOrder.certificate; } } /** * Download the certificate chain (PEM) from the order's certificate URL */ async getCertificate(order: IAcmeOrder): Promise { if (!order.certificate) { throw new Error('Order does not have a certificate URL - finalize first'); } const response = await this.httpClient.signedRequest(order.certificate, null); // Certificate chain is returned as PEM text return typeof response.data === 'string' ? response.data : response.data.toString(); } /** * Poll an ACME resource (order or challenge) until it reaches 'valid' or 'ready' status. * Uses exponential backoff with Retry-After header support. */ async waitForValidStatus( item: { url: string }, opts?: { maxAttempts?: number; initialDelayMs?: number }, ): Promise { const maxAttempts = opts?.maxAttempts ?? 30; const initialDelay = opts?.initialDelayMs ?? 1000; for (let i = 0; i < maxAttempts; i++) { const response = await this.httpClient.signedRequest(item.url, null); const body = response.data; if (body.status === 'valid' || body.status === 'ready') { return body; } if (body.status === 'invalid') { const challengeError = body.challenges?.find((c: any) => c.error)?.error; throw new AcmeError({ status: 0, type: challengeError?.type || 'urn:ietf:params:acme:error:rejectedIdentifier', detail: challengeError?.detail || JSON.stringify(body), subproblems: challengeError?.subproblems, url: item.url, }); } // Respect Retry-After header, otherwise exponential backoff const retryAfter = parseInt(response.headers['retry-after'] || '0', 10); const delay = retryAfter > 0 ? retryAfter * 1000 : Math.min(initialDelay * Math.pow(2, i), 30000); await new Promise((resolve) => setTimeout(resolve, delay)); } throw new Error(`Timeout waiting for valid status after ${maxAttempts} attempts`); } }