@push.rocks/smartacme 9.0.0 โ†’ 9.1.0

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/readme.hints.md CHANGED
@@ -28,6 +28,21 @@ Key files:
28
28
 
29
29
  Usage in `ts/plugins.ts`: `import * as acme from './acme/index.js'` (replaces `acme-client`)
30
30
 
31
+ ## Concurrency & Rate Limiting (taskbuffer integration)
32
+
33
+ As of v9.1.0, `@push.rocks/lik.InterestMap` was replaced with `@push.rocks/taskbuffer.TaskManager` for coordinating concurrent certificate requests. This provides:
34
+
35
+ - **Per-domain mutex** (`cert-domain-mutex`): Only one ACME issuance per TLD at a time, with `resultSharingMode: 'share-latest'` so queued callers get the same result without re-issuing.
36
+ - **Global concurrency cap** (`acme-global-concurrency`): Limits total parallel ACME operations (default 5, configurable via `maxConcurrentIssuances`).
37
+ - **Account-level rate limiting** (`acme-account-rate-limit`): Sliding-window rate limit (default 250 orders per 3 hours, configurable via `maxOrdersPerWindow`/`orderWindowMs`) to stay under Let's Encrypt limits.
38
+ - **Step-based progress**: The cert issuance task uses `notifyStep()` for prepare/authorize/finalize/store phases, observable via `smartAcme.certIssuanceEvents`.
39
+
40
+ Key implementation details:
41
+ - A single reusable `Task` named `cert-issuance` handles all domains via `triggerTaskConstrained()` with different inputs.
42
+ - The `shouldExecute` callback on the domain mutex checks the certmanager cache as a safety net.
43
+ - `TaskManager.start()` is called in `SmartAcme.start()` and `TaskManager.stop()` in `SmartAcme.stop()`.
44
+ - The "no cronjobs specified" log messages during tests come from taskbuffer's internal CronManager polling โ€” harmless noise when no cron tasks are scheduled.
45
+
31
46
  ## Dependency Notes
32
47
 
33
48
  - `acme-client` was replaced with custom implementation in `ts/acme/` + `@peculiar/x509` for CSR generation
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @push.rocks/smartacme
2
2
 
3
- A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power. ๐Ÿ”’
3
+ A TypeScript-based ACME client for Let's Encrypt certificate management with a focus on simplicity and power. ๐Ÿ”’
4
4
 
5
5
  ## Issue Reporting and Security
6
6
 
@@ -16,9 +16,9 @@ Ensure your project uses TypeScript and ECMAScript Modules (ESM).
16
16
 
17
17
  ## Usage
18
18
 
19
- `@push.rocks/smartacme` automates the full ACME certificate lifecycle โ€” obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It supports pluggable challenge handlers (DNS-01, HTTP-01) and pluggable certificate storage backends (MongoDB, in-memory, or your own).
19
+ `@push.rocks/smartacme` automates the full ACME certificate lifecycle โ€” obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It features a built-in RFC 8555-compliant ACME protocol implementation, pluggable challenge handlers (DNS-01, HTTP-01), pluggable certificate storage backends (MongoDB, in-memory, or your own), and structured error handling with smart retry logic.
20
20
 
21
- ### Quick Start
21
+ ### ๐Ÿš€ Quick Start
22
22
 
23
23
  ```typescript
24
24
  import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme';
@@ -47,39 +47,40 @@ await smartAcme.start();
47
47
 
48
48
  // 4. Get a certificate
49
49
  const cert = await smartAcme.getCertificateForDomain('example.com');
50
- console.log(cert.publicKey); // PEM certificate
50
+ console.log(cert.publicKey); // PEM certificate chain
51
51
  console.log(cert.privateKey); // PEM private key
52
52
 
53
53
  // 5. Clean up
54
54
  await smartAcme.stop();
55
55
  ```
56
56
 
57
- ### SmartAcme Options
57
+ ### โš™๏ธ SmartAcme Options
58
58
 
59
59
  ```typescript
60
60
  interface ISmartAcmeOptions {
61
61
  accountEmail: string; // ACME account email
62
62
  accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
63
63
  certManager: ICertManager; // Certificate storage backend
64
- environment: 'production' | 'integration'; // LetsEncrypt environment
64
+ environment: 'production' | 'integration'; // Let's Encrypt environment
65
65
  challengeHandlers: IChallengeHandler[]; // At least one handler required
66
66
  challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
67
67
  retryOptions?: { // Optional retry/backoff config
68
- retries?: number;
69
- factor?: number;
70
- minTimeoutMs?: number;
71
- maxTimeoutMs?: number;
68
+ retries?: number; // Default: 10
69
+ factor?: number; // Default: 4
70
+ minTimeoutMs?: number; // Default: 1000
71
+ maxTimeoutMs?: number; // Default: 60000
72
72
  };
73
73
  }
74
74
  ```
75
75
 
76
- ### Getting Certificates
76
+ ### ๐Ÿ“œ Getting Certificates
77
77
 
78
78
  ```typescript
79
79
  // Standard certificate for a single domain
80
80
  const cert = await smartAcme.getCertificateForDomain('example.com');
81
81
 
82
- // Include wildcard certificate (requires DNS-01 handler)
82
+ // Include wildcard coverage (requires DNS-01 handler)
83
+ // Issues a single cert covering example.com AND *.example.com
83
84
  const certWithWildcard = await smartAcme.getCertificateForDomain('example.com', {
84
85
  includeWildcard: true,
85
86
  });
@@ -90,25 +91,32 @@ const wildcardCert = await smartAcme.getCertificateForDomain('*.example.com');
90
91
 
91
92
  Certificates are automatically cached and reused when still valid. Renewal happens automatically when a certificate is within 10 days of expiration.
92
93
 
93
- ### Certificate Object
94
+ ### ๐Ÿ“ฆ Certificate Object
94
95
 
95
- The returned `SmartacmeCert` object has these properties:
96
+ The returned `SmartacmeCert` (also exported as `Cert`) object has these properties:
96
97
 
97
98
  | Property | Type | Description |
98
99
  |-------------|----------|--------------------------------------|
99
100
  | `id` | `string` | Unique certificate identifier |
100
101
  | `domainName`| `string` | Domain the cert is issued for |
101
- | `publicKey` | `string` | PEM-encoded certificate |
102
+ | `publicKey` | `string` | PEM-encoded certificate chain |
102
103
  | `privateKey`| `string` | PEM-encoded private key |
103
104
  | `csr` | `string` | Certificate Signing Request |
104
105
  | `created` | `number` | Timestamp of creation |
105
106
  | `validUntil`| `number` | Timestamp of expiration |
106
107
 
108
+ Useful methods:
109
+
110
+ ```typescript
111
+ cert.isStillValid(); // true if not expired
112
+ cert.shouldBeRenewed(); // true if expires within 10 days
113
+ ```
114
+
107
115
  ## Certificate Managers
108
116
 
109
117
  SmartAcme uses the `ICertManager` interface for pluggable certificate storage.
110
118
 
111
- ### MongoCertManager
119
+ ### ๐Ÿ—„๏ธ MongoCertManager
112
120
 
113
121
  Persistent storage backed by MongoDB using `@push.rocks/smartdata`:
114
122
 
@@ -122,7 +130,7 @@ const certManager = new certmanagers.MongoCertManager({
122
130
  });
123
131
  ```
124
132
 
125
- ### MemoryCertManager
133
+ ### ๐Ÿงช MemoryCertManager
126
134
 
127
135
  In-memory storage, ideal for testing or ephemeral workloads:
128
136
 
@@ -132,13 +140,12 @@ import { certmanagers } from '@push.rocks/smartacme';
132
140
  const certManager = new certmanagers.MemoryCertManager();
133
141
  ```
134
142
 
135
- ### Custom Certificate Manager
143
+ ### ๐Ÿ”ง Custom Certificate Manager
136
144
 
137
145
  Implement the `ICertManager` interface for your own storage backend:
138
146
 
139
147
  ```typescript
140
- import type { ICertManager } from '@push.rocks/smartacme';
141
- import { Cert } from '@push.rocks/smartacme';
148
+ import type { ICertManager, Cert } from '@push.rocks/smartacme';
142
149
 
143
150
  class RedisCertManager implements ICertManager {
144
151
  async init(): Promise<void> { /* connect */ }
@@ -166,7 +173,7 @@ const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
166
173
  const dnsHandler = new handlers.Dns01Handler(cfAccount);
167
174
  ```
168
175
 
169
- DNS-01 is required for wildcard certificates and works regardless of server accessibility.
176
+ DNS-01 is **required** for wildcard certificates and works regardless of server accessibility.
170
177
 
171
178
  ### ๐Ÿ“ Http01Webroot
172
179
 
@@ -197,12 +204,12 @@ app.use((req, res, next) => memHandler.handleRequest(req, res, next));
197
204
 
198
205
  Perfect for serverless or container environments where filesystem access is limited.
199
206
 
200
- ### Custom Challenge Handler
207
+ ### ๐Ÿ”ง Custom Challenge Handler
201
208
 
202
209
  Implement `IChallengeHandler<T>` for custom challenge types:
203
210
 
204
211
  ```typescript
205
- import type { IChallengeHandler } from '@push.rocks/smartacme';
212
+ import type { handlers } from '@push.rocks/smartacme';
206
213
 
207
214
  interface MyChallenge {
208
215
  type: string;
@@ -210,28 +217,54 @@ interface MyChallenge {
210
217
  keyAuthorization: string;
211
218
  }
212
219
 
213
- class MyHandler implements IChallengeHandler<MyChallenge> {
220
+ class MyHandler implements handlers.IChallengeHandler<MyChallenge> {
214
221
  getSupportedTypes(): string[] { return ['http-01']; }
215
- async prepare(ch: MyChallenge): Promise<void> { /* ... */ }
216
- async cleanup(ch: MyChallenge): Promise<void> { /* ... */ }
222
+ async prepare(ch: MyChallenge): Promise<void> { /* set up challenge response */ }
223
+ async cleanup(ch: MyChallenge): Promise<void> { /* tear down */ }
217
224
  async checkWetherDomainIsSupported(domain: string): Promise<boolean> { return true; }
218
225
  }
219
226
  ```
220
227
 
228
+ ## Error Handling
229
+
230
+ SmartAcme provides structured ACME error handling via the `AcmeError` class, which carries full RFC 8555 error information:
231
+
232
+ ```typescript
233
+ import { AcmeError } from '@push.rocks/smartacme/ts/acme/acme.classes.error.js';
234
+
235
+ try {
236
+ const cert = await smartAcme.getCertificateForDomain('example.com');
237
+ } catch (err) {
238
+ if (err instanceof AcmeError) {
239
+ console.log(err.status); // HTTP status code (e.g. 429)
240
+ console.log(err.type); // ACME error URN (e.g. 'urn:ietf:params:acme:error:rateLimited')
241
+ console.log(err.detail); // Human-readable message
242
+ console.log(err.subproblems); // Per-identifier sub-errors (RFC 8555 ยง6.7.1)
243
+ console.log(err.retryAfter); // Retry-After value in seconds
244
+ console.log(err.isRateLimited); // true for 429 or rateLimited type
245
+ console.log(err.isRetryable); // true for 429, 503, 5xx, badNonce; false for 403/404/409
246
+ }
247
+ }
248
+ ```
249
+
250
+ The built-in retry logic is **error-aware**: non-retryable errors (403, 404, 409) are thrown immediately without wasting retry attempts, and rate-limited responses respect the server's `Retry-After` header instead of using blind exponential backoff.
251
+
221
252
  ## Domain Matching
222
253
 
223
254
  SmartAcme automatically maps subdomains to their base domain for certificate lookups:
224
255
 
225
- ```typescript
226
- // subdomain.example.com โ†’ certificate for example.com
227
- // *.example.com โ†’ certificate for example.com
228
- // a.b.example.com โ†’ not supported (4+ level domains)
256
+ ```
257
+ subdomain.example.com โ†’ certificate for example.com โœ…
258
+ *.example.com โ†’ certificate for example.com โœ…
259
+ a.b.example.com โ†’ not supported (4+ levels) โŒ
229
260
  ```
230
261
 
231
262
  ## Environment
232
263
 
233
- - **`production`** โ€” Uses LetsEncrypt production servers. Rate limits apply.
234
- - **`integration`** โ€” Uses LetsEncrypt staging servers. No rate limits, but certificates are not trusted by browsers. Use for testing.
264
+ | Environment | Description |
265
+ |----------------|-------------|
266
+ | `production` | Let's Encrypt production servers. Certificates are browser-trusted. [Rate limits](https://letsencrypt.org/docs/rate-limits/) apply. |
267
+ | `integration` | Let's Encrypt staging servers. No rate limits, but certificates are **not** browser-trusted. Use for testing. |
235
268
 
236
269
  ## Complete Example with HTTP-01
237
270
 
@@ -269,13 +302,20 @@ await smartAcme.stop();
269
302
  server.close();
270
303
  ```
271
304
 
272
- ## Testing
305
+ ## Architecture
273
306
 
274
- ```bash
275
- pnpm test
276
- ```
307
+ Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules:
308
+
309
+ | Module | Purpose |
310
+ |--------|---------|
311
+ | `AcmeClient` | Top-level ACME facade โ€” orders, authorizations, finalization |
312
+ | `AcmeCrypto` | RSA key generation, JWK/JWS (RFC 7515/7638), CSR via `@peculiar/x509` |
313
+ | `AcmeHttpClient` | JWS-signed HTTP transport with nonce management and structured logging |
314
+ | `AcmeError` | Structured error class with type URN, subproblems, Retry-After, retryability |
315
+ | `AcmeOrderManager` | Order lifecycle โ€” create, poll, finalize, download certificate |
316
+ | `AcmeChallengeManager` | Key authorization computation and challenge completion |
277
317
 
278
- Tests use `@git.zone/tstest` with the tapbundle assertion library.
318
+ All cryptographic operations use `node:crypto`. The only external crypto dependency is `@peculiar/x509` for CSR generation.
279
319
 
280
320
  ## License and Legal Information
281
321
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartacme',
6
- version: '9.0.0',
6
+ version: '9.1.0',
7
7
  description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
8
8
  }
@@ -96,4 +96,11 @@ export class AcmeClient {
96
96
  async getCertificate(order: IAcmeOrder): Promise<string> {
97
97
  return this.orderManager.getCertificate(order);
98
98
  }
99
+
100
+ /**
101
+ * Destroy HTTP transport to release sockets and allow process exit.
102
+ */
103
+ destroy(): void {
104
+ this.httpClient.destroy();
105
+ }
99
106
  }
@@ -17,11 +17,23 @@ export class AcmeHttpClient {
17
17
  private nonce: string | null = null;
18
18
  public kid: string | null = null;
19
19
  private logger?: TAcmeLogger;
20
+ private httpsAgent: https.Agent;
21
+ private httpAgent: http.Agent;
20
22
 
21
23
  constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
22
24
  this.directoryUrl = directoryUrl;
23
25
  this.accountKeyPem = accountKeyPem;
24
26
  this.logger = logger;
27
+ this.httpsAgent = new https.Agent({ keepAlive: false });
28
+ this.httpAgent = new http.Agent({ keepAlive: false });
29
+ }
30
+
31
+ /**
32
+ * Destroy HTTP agents to release sockets and allow process exit.
33
+ */
34
+ destroy(): void {
35
+ this.httpsAgent.destroy();
36
+ this.httpAgent.destroy();
25
37
  }
26
38
 
27
39
  private log(level: string, message: string, data?: any): void {
@@ -186,6 +198,7 @@ export class AcmeHttpClient {
186
198
  path: urlObj.pathname + urlObj.search,
187
199
  method,
188
200
  headers: requestHeaders,
201
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
189
202
  };
190
203
 
191
204
  const req = lib.request(options, (res) => {
package/ts/index.ts CHANGED
@@ -9,3 +9,6 @@ export { certmanagers };
9
9
  // handlers
10
10
  import * as handlers from './handlers/index.js';
11
11
  export { handlers };
12
+
13
+ // re-export taskbuffer event types for consumers
14
+ export type { ITaskEvent, ITaskMetadata } from '@push.rocks/taskbuffer';
package/ts/plugins.ts CHANGED
@@ -19,6 +19,7 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
19
19
  import * as smartunique from '@push.rocks/smartunique';
20
20
  import * as smartstring from '@push.rocks/smartstring';
21
21
  import * as smarttime from '@push.rocks/smarttime';
22
+ import * as taskbuffer from '@push.rocks/taskbuffer';
22
23
 
23
24
  export {
24
25
  lik,
@@ -30,6 +31,7 @@ export {
30
31
  smartunique,
31
32
  smartstring,
32
33
  smarttime,
34
+ taskbuffer,
33
35
  };
34
36
 
35
37
  // @tsclass scope