Compare commits

..

10 Commits

Author SHA1 Message Date
77d40985f3 v9.1.3
Some checks failed
Default (tags) / security (push) Successful in 1m49s
Default (tags) / test (push) Failing after 1m20s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-16 02:46:54 +00:00
adf9262ded fix(smartacme): Include base domain alongside wildcard when building identifiers for wildcard certificate requests 2026-02-16 02:46:54 +00:00
e2d182ca03 v9.1.2
Some checks failed
Default (tags) / security (push) Successful in 45s
Default (tags) / test (push) Failing after 2m38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-15 23:31:42 +00:00
8cd713447e fix(docs): document built-in concurrency control, rate limiting, and request deduplication in README 2026-02-15 23:31:42 +00:00
2cf3dbdd95 v9.1.1
Some checks failed
Default (tags) / security (push) Successful in 1m44s
Default (tags) / test (push) Failing after 1m38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-15 23:23:54 +00:00
1c75bac44f fix(deps): bump @push.rocks/smarttime to ^4.2.3 and @push.rocks/taskbuffer to ^6.1.2 2026-02-15 23:23:54 +00:00
a865fb89e0 v9.1.0
Some checks failed
Default (tags) / security (push) Successful in 49s
Default (tags) / test (push) Failing after 1m25s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-15 22:22:12 +00:00
cfc0695c8a feat(smartacme): Integrate @push.rocks/taskbuffer TaskManager to coordinate ACME certificate issuance with per-domain mutex, global concurrency cap, and account-level rate limiting; refactor issuance flow into a single reusable cert-issuance task, expose issuance events, and update lifecycle to start/stop the TaskManager. Add configuration for concurrent issuances and sliding-window order limits, export taskbuffer types/plugins, and update tests and docs accordingly. 2026-02-15 22:22:12 +00:00
68178366d5 v9.0.1
Some checks failed
Default (tags) / security (push) Successful in 1m48s
Default (tags) / test (push) Failing after 1m40s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-15 20:43:06 +00:00
52e1295fd2 fix(acme-http-client): Destroy keep-alive HTTP agents and DNS client on shutdown to allow process exit; add destroy() on AcmeHttpClient and AcmeClient, wire agents into requests, and call client/smartdns destroy during SmartAcme.stop; documentation clarifications and expanded README (error handling, examples, default retry values). 2026-02-15 20:43:06 +00:00
12 changed files with 512 additions and 169 deletions

View File

@@ -1,5 +1,46 @@
# Changelog # Changelog
## 2026-02-16 - 9.1.3 - fix(smartacme)
Include base domain alongside wildcard when building identifiers for wildcard certificate requests
- When isWildcardRequest is true, the base domain (e.g. example.com) is now added in addition to the wildcard (*.example.com) so the issued certificate covers both apex and wildcard entries.
- Prevents missing SAN for the apex domain when requesting wildcard certificates.
## 2026-02-15 - 9.1.2 - fix(docs)
document built-in concurrency control, rate limiting, and request deduplication in README
- Added a new 'Concurrency Control & Rate Limiting' section to the README describing per-domain mutex, global concurrency cap, and sliding-window account rate limiting (defaults: 1 per domain, 5 global, 250 per 3 hours).
- Documented new SmartAcme options in the interface: maxConcurrentIssuances, maxOrdersPerWindow, and orderWindowMs.
- Added example code showing configuration of the limits and an example of request deduplication behavior (multiple subdomain requests resolving to a single ACME order).
- Added an example subscription to certIssuanceEvents and updated the components table with TaskManager entry.
- Change is documentation-only (README) — no code changes; safe patch release.
## 2026-02-15 - 9.1.1 - fix(deps)
bump @push.rocks/smarttime to ^4.2.3 and @push.rocks/taskbuffer to ^6.1.2
- @push.rocks/smarttime: ^4.1.1 -> ^4.2.3
- @push.rocks/taskbuffer: ^6.1.0 -> ^6.1.2
- Only package.json dependency version updates; no code changes
## 2026-02-15 - 9.1.0 - feat(smartacme)
Integrate @push.rocks/taskbuffer TaskManager to coordinate ACME certificate issuance with per-domain mutex, global concurrency cap, and account-level rate limiting; refactor issuance flow into a single reusable cert-issuance task, expose issuance events, and update lifecycle to start/stop the TaskManager. Add configuration for concurrent issuances and sliding-window order limits, export taskbuffer types/plugins, and update tests and docs accordingly.
- Added dependency @push.rocks/taskbuffer and re-exported ITaskEvent/ITaskMetadata in ts/index.ts; also imported/exported taskbuffer in ts/plugins.ts.
- Replaced interestMap coordination with TaskManager + TaskConstraintGroup(s): 'cert-domain-mutex' (per-domain mutex, resultSharingMode: 'share-latest'), 'acme-global-concurrency' (global concurrency cap), and 'acme-account-rate-limit' (sliding-window rate limiter).
- Introduced a single reusable Task named 'cert-issuance' and moved the ACME issuance flow into performCertificateIssuance(), splitting progress into named steps (prepare/authorize/finalize/store) and using notifyStep() for observable progress.
- Exposed certIssuanceEvents via SmartAcme.certIssuanceEvents and wired TaskManager.start()/stop() into SmartAcme.start()/stop().
- Added new ISmartAcmeOptions: maxConcurrentIssuances, maxOrdersPerWindow, orderWindowMs to control concurrency and rate limiting.
- Updated tests to remove interestMap stubs and adapt to the taskbuffer-based flow; cleaned up client/retry stubbing in tests.
- Updated readme.hints.md with guidance on concurrency, rate limiting, and taskbuffer integration.
## 2026-02-15 - 9.0.1 - fix(acme-http-client)
Destroy keep-alive HTTP agents and DNS client on shutdown to allow process exit; add destroy() on AcmeHttpClient and AcmeClient, wire agents into requests, and call client/smartdns destroy during SmartAcme.stop; documentation clarifications and expanded README (error handling, examples, default retry values).
- ts/acme/acme.classes.http-client.ts: added per-protocol http/https agents (keepAlive: false), use agent for outgoing requests, and added destroy() to explicitly destroy agents and free sockets.
- ts/acme/acme.classes.client.ts: added destroy() that forwards to the HTTP client to allow transport cleanup.
- ts/smartacme.classes.smartacme.ts: SmartAcme.stop now calls client.destroy() and smartdns.destroy() (when present) to ensure child processes and sockets are terminated before exit; also ensures certmanager.close() is awaited.
- readme.md: documentation improvements and clarifications (Lets Encrypt spelling, added RFC 8555 compliance note, error handling / AcmeError usage examples, default retry parameter docs, UI/emoji improvements, and other wording/formatting updates).
## 2026-02-15 - 9.0.0 - BREAKING CHANGE(acme) ## 2026-02-15 - 9.0.0 - BREAKING CHANGE(acme)
Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartacme", "name": "@push.rocks/smartacme",
"version": "9.0.0", "version": "9.1.3",
"private": false, "private": false,
"description": "A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.", "description": "A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -48,8 +48,9 @@
"@push.rocks/smartlog": "^3.1.10", "@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartstring": "^4.1.0", "@push.rocks/smartstring": "^4.1.0",
"@push.rocks/smarttime": "^4.1.1", "@push.rocks/smarttime": "^4.2.3",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^6.1.2",
"@tsclass/tsclass": "^9.3.0" "@tsclass/tsclass": "^9.3.0"
}, },
"devDependencies": { "devDependencies": {

125
pnpm-lock.yaml generated
View File

@@ -36,11 +36,14 @@ importers:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
'@push.rocks/smarttime': '@push.rocks/smarttime':
specifier: ^4.1.1 specifier: ^4.2.3
version: 4.1.1 version: 4.2.3
'@push.rocks/smartunique': '@push.rocks/smartunique':
specifier: ^3.0.9 specifier: ^3.0.9
version: 3.0.9 version: 3.0.9
'@push.rocks/taskbuffer':
specifier: ^6.1.2
version: 6.1.2
'@tsclass/tsclass': '@tsclass/tsclass':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0 version: 9.3.0
@@ -1012,8 +1015,8 @@ packages:
'@push.rocks/smartstring@4.1.0': '@push.rocks/smartstring@4.1.0':
resolution: {integrity: sha512-Q4py/Nm3KTDhQ9EiC75yBtSTLR0KLMwhKM+8gGcutgKotZT6wJ3gncjmtD8LKFfNhb4lSaFMgPJgLrCHTOH6Iw==} resolution: {integrity: sha512-Q4py/Nm3KTDhQ9EiC75yBtSTLR0KLMwhKM+8gGcutgKotZT6wJ3gncjmtD8LKFfNhb4lSaFMgPJgLrCHTOH6Iw==}
'@push.rocks/smarttime@4.1.1': '@push.rocks/smarttime@4.2.3':
resolution: {integrity: sha512-Ha/3J/G+zfTl4ahpZgF6oUOZnUjpLhrBja0OQ2cloFxF9sKT8I1COaSqIfBGDtoK2Nly4UD4aTJ3JcJNOg/kgA==} resolution: {integrity: sha512-8gMg8RUkrCG4p9NcEUZV7V6KpL24+jAMK02g7qyhfA6giz/JJWD0+8w8xjSR+G7qe16KVQ2y3RbvAL9TxmO36g==}
'@push.rocks/smartunique@3.0.9': '@push.rocks/smartunique@3.0.9':
resolution: {integrity: sha512-q6DYQgT7/dqdWi9HusvtWCjdsFzLFXY9LTtaZV6IYNJt6teZOonoygxTdNt9XLn6niBSbLYrHSKvJNTRH/uK+g==} resolution: {integrity: sha512-q6DYQgT7/dqdWi9HusvtWCjdsFzLFXY9LTtaZV6IYNJt6teZOonoygxTdNt9XLn6niBSbLYrHSKvJNTRH/uK+g==}
@@ -1033,12 +1036,12 @@ packages:
'@push.rocks/smartyaml@3.0.4': '@push.rocks/smartyaml@3.0.4':
resolution: {integrity: sha512-1JRt+hnoc2zHw3AW+vXKlCdSVwqOmY/01fu+2HBviS0UDjoZCa+/rp6E3GaQb5lEEafKi8ENbffAfjXXp3N2xQ==} resolution: {integrity: sha512-1JRt+hnoc2zHw3AW+vXKlCdSVwqOmY/01fu+2HBviS0UDjoZCa+/rp6E3GaQb5lEEafKi8ENbffAfjXXp3N2xQ==}
'@push.rocks/taskbuffer@3.1.7':
resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==}
'@push.rocks/taskbuffer@3.5.0': '@push.rocks/taskbuffer@3.5.0':
resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==} resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==}
'@push.rocks/taskbuffer@6.1.2':
resolution: {integrity: sha512-sdqKd8N/GidztQ1k3r8A86rLvD8Afyir5FjYCNJXDD9837JLoqzHaOKGltUSBsCGh2gjsZn6GydsY6HhXQgvZQ==}
'@push.rocks/webrequest@3.0.37': '@push.rocks/webrequest@3.0.37':
resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==} resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==}
@@ -2290,6 +2293,10 @@ packages:
typescript: typescript:
optional: true optional: true
croner@10.0.1:
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
engines: {node: '>=18.0'}
croner@4.4.1: croner@4.4.1:
resolution: {integrity: sha512-aqVeeIPCf5/NZFlz4mN4MLEOs9xf4ODCmHQDs+577JFj8mK3RkKJz77h7+Rn94AijUqKdFNOUHM+v88d8p02UQ==} resolution: {integrity: sha512-aqVeeIPCf5/NZFlz4mN4MLEOs9xf4ODCmHQDs+577JFj8mK3RkKJz77h7+Rn94AijUqKdFNOUHM+v88d8p02UQ==}
@@ -2297,10 +2304,6 @@ packages:
resolution: {integrity: sha512-9pSLe+tDJnmNak2JeMkz6ZmTCXP5p6vCxSd4kvDqrTJkqAP62j2uAEIZjf8cPDZIakStujqVzh5Y5MIWH3yYAw==} resolution: {integrity: sha512-9pSLe+tDJnmNak2JeMkz6ZmTCXP5p6vCxSd4kvDqrTJkqAP62j2uAEIZjf8cPDZIakStujqVzh5Y5MIWH3yYAw==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
croner@9.0.0:
resolution: {integrity: sha512-onMB0OkDjkXunhdW9htFjEhqrD54+M94i6ackoUkjHKbRnXdyEyKRelp4nJ1kAz32+s27jP1FsebpJCVl0BsvA==}
engines: {node: '>=18.0'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2319,6 +2322,9 @@ packages:
dayjs@1.11.13: dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
debug@2.6.9: debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies: peerDependencies:
@@ -3584,8 +3590,8 @@ packages:
resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==} resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
pretty-ms@9.2.0: pretty-ms@9.3.0:
resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
progress@2.0.3: progress@2.0.3:
@@ -4292,7 +4298,7 @@ snapshots:
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
'@push.rocks/smartsitemap': 2.0.4 '@push.rocks/smartsitemap': 2.0.4
'@push.rocks/smartstream': 3.2.5 '@push.rocks/smartstream': 3.2.5
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
'@push.rocks/taskbuffer': 3.5.0 '@push.rocks/taskbuffer': 3.5.0
'@push.rocks/webrequest': 3.0.37 '@push.rocks/webrequest': 3.0.37
'@push.rocks/webstore': 2.0.20 '@push.rocks/webstore': 2.0.20
@@ -5533,8 +5539,11 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit'
- aws-crt - aws-crt
- react
- supports-color - supports-color
- vue
'@git.zone/tsbundle@2.8.3': '@git.zone/tsbundle@2.8.3':
dependencies: dependencies:
@@ -5556,8 +5565,11 @@ snapshots:
rolldown: 1.0.0-beta.52 rolldown: 1.0.0-beta.52
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit'
- '@swc/helpers' - '@swc/helpers'
- react
- supports-color - supports-color
- vue
'@git.zone/tspublish@1.11.0': '@git.zone/tspublish@1.11.0':
dependencies: dependencies:
@@ -5573,8 +5585,11 @@ snapshots:
'@push.rocks/smartrequest': 5.0.1 '@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartshell': 3.3.0 '@push.rocks/smartshell': 3.3.0
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit'
- aws-crt - aws-crt
- react
- supports-color - supports-color
- vue
'@git.zone/tsrun@2.0.1': '@git.zone/tsrun@2.0.1':
dependencies: dependencies:
@@ -5605,7 +5620,7 @@ snapshots:
'@push.rocks/smartrequest': 5.0.1 '@push.rocks/smartrequest': 5.0.1
'@push.rocks/smarts3': 3.0.3 '@push.rocks/smarts3': 3.0.3
'@push.rocks/smartshell': 3.3.0 '@push.rocks/smartshell': 3.3.0
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
'@types/ws': 8.18.1 '@types/ws': 8.18.1
figures: 6.1.0 figures: 6.1.0
ws: 8.19.0 ws: 8.19.0
@@ -5945,10 +5960,14 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartstring': 4.1.0 '@push.rocks/smartstring': 4.1.0
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer': 3.1.7 '@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 4.4.4 '@tsclass/tsclass': 4.4.4
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit'
- aws-crt - aws-crt
- react
- supports-color
- vue
'@push.rocks/lik@6.2.2': '@push.rocks/lik@6.2.2':
dependencies: dependencies:
@@ -5956,7 +5975,7 @@ snapshots:
'@push.rocks/smartmatch': 2.0.0 '@push.rocks/smartmatch': 2.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
'@types/minimatch': 5.1.2 '@types/minimatch': 5.1.2
'@types/symbol-tree': 3.2.5 '@types/symbol-tree': 3.2.5
symbol-tree: 3.2.4 symbol-tree: 3.2.4
@@ -5982,8 +6001,13 @@ snapshots:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
'@push.rocks/taskbuffer': 3.1.7 '@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.3.0 '@tsclass/tsclass': 9.3.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/qenv@6.1.3': '@push.rocks/qenv@6.1.3':
dependencies: dependencies:
@@ -6106,21 +6130,24 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstring': 4.1.0 '@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer': 3.1.7 '@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 8.2.1 '@tsclass/tsclass': 8.2.1
mongodb: 6.16.0 mongodb: 6.16.0
transitivePeerDependencies: transitivePeerDependencies:
- '@aws-sdk/credential-providers' - '@aws-sdk/credential-providers'
- '@mongodb-js/zstd' - '@mongodb-js/zstd'
- '@nuxt/kit'
- aws-crt - aws-crt
- gcp-metadata - gcp-metadata
- kerberos - kerberos
- mongodb-client-encryption - mongodb-client-encryption
- react
- snappy - snappy
- socks - socks
- supports-color - supports-color
- vue
'@push.rocks/smartdata@7.0.15': '@push.rocks/smartdata@7.0.15':
dependencies: dependencies:
@@ -6131,7 +6158,7 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstring': 4.1.0 '@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer': 3.5.0 '@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.3.0 '@tsclass/tsclass': 9.3.0
@@ -6302,7 +6329,7 @@ snapshots:
'@push.rocks/smartfile': 11.2.7 '@push.rocks/smartfile': 11.2.7
'@push.rocks/smarthash': 3.2.6 '@push.rocks/smarthash': 3.2.6
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
'@push.rocks/webrequest': 4.0.2 '@push.rocks/webrequest': 4.0.2
'@tsclass/tsclass': 9.3.0 '@tsclass/tsclass': 9.3.0
@@ -6347,13 +6374,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@aws-sdk/credential-providers' - '@aws-sdk/credential-providers'
- '@mongodb-js/zstd' - '@mongodb-js/zstd'
- '@nuxt/kit'
- aws-crt - aws-crt
- gcp-metadata - gcp-metadata
- kerberos - kerberos
- mongodb-client-encryption - mongodb-client-encryption
- react
- snappy - snappy
- socks - socks
- supports-color - supports-color
- vue
'@push.rocks/smartnetwork@3.0.2': '@push.rocks/smartnetwork@3.0.2':
dependencies: dependencies:
@@ -6385,12 +6415,15 @@ snapshots:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 4.4.2 '@push.rocks/smartrequest': 4.4.2
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
'@push.rocks/smartversion': 3.0.5 '@push.rocks/smartversion': 3.0.5
package-json: 8.1.1 package-json: 8.1.1
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit'
- aws-crt - aws-crt
- react
- supports-color - supports-color
- vue
'@push.rocks/smartntml@2.0.8': '@push.rocks/smartntml@2.0.8':
dependencies: dependencies:
@@ -6555,7 +6588,7 @@ snapshots:
'@push.rocks/smartlog': 3.1.11 '@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
engine.io: 6.6.4 engine.io: 6.6.4
socket.io: 4.8.1 socket.io: 4.8.1
socket.io-client: 4.8.1 socket.io-client: 4.8.1
@@ -6605,16 +6638,16 @@ snapshots:
dependencies: dependencies:
'@push.rocks/isounique': 1.0.5 '@push.rocks/isounique': 1.0.5
'@push.rocks/smarttime@4.1.1': '@push.rocks/smarttime@4.2.3':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
croner: 9.0.0 croner: 10.0.1
date-fns: 4.1.0 date-fns: 4.1.0
dayjs: 1.11.13 dayjs: 1.11.19
is-nan: 1.3.2 is-nan: 1.3.2
pretty-ms: 9.2.0 pretty-ms: 9.3.0
'@push.rocks/smartunique@3.0.9': '@push.rocks/smartunique@3.0.9':
dependencies: dependencies:
@@ -6642,16 +6675,6 @@ snapshots:
dependencies: dependencies:
yaml: 2.8.2 yaml: 2.8.2
'@push.rocks/taskbuffer@3.1.7':
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1
'@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer@3.5.0': '@push.rocks/taskbuffer@3.5.0':
dependencies: dependencies:
'@design.estate/dees-element': 2.1.6 '@design.estate/dees-element': 2.1.6
@@ -6660,7 +6683,23 @@ snapshots:
'@push.rocks/smartlog': 3.1.11 '@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1 '@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/taskbuffer@6.1.2':
dependencies:
'@design.estate/dees-element': 2.1.6
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit' - '@nuxt/kit'
@@ -8224,12 +8263,12 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
croner@10.0.1: {}
croner@4.4.1: {} croner@4.4.1: {}
croner@5.7.0: {} croner@5.7.0: {}
croner@9.0.0: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -8246,6 +8285,8 @@ snapshots:
dayjs@1.11.13: {} dayjs@1.11.13: {}
dayjs@1.11.19: {}
debug@2.6.9: debug@2.6.9:
dependencies: dependencies:
ms: 2.0.0 ms: 2.0.0
@@ -9729,7 +9770,7 @@ snapshots:
dependencies: dependencies:
parse-ms: 3.0.0 parse-ms: 3.0.0
pretty-ms@9.2.0: pretty-ms@9.3.0:
dependencies: dependencies:
parse-ms: 4.0.0 parse-ms: 4.0.0

View File

@@ -28,6 +28,21 @@ Key files:
Usage in `ts/plugins.ts`: `import * as acme from './acme/index.js'` (replaces `acme-client`) Usage in `ts/plugins.ts`: `import * as acme from './acme/index.js'` (replaces `acme-client`)
## Concurrency & Rate Limiting (taskbuffer integration)
As of v9.1.0, `@push.rocks/lik.InterestMap` was replaced with `@push.rocks/taskbuffer.TaskManager` for coordinating concurrent certificate requests. This provides:
- **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.
- **Global concurrency cap** (`acme-global-concurrency`): Limits total parallel ACME operations (default 5, configurable via `maxConcurrentIssuances`).
- **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.
- **Step-based progress**: The cert issuance task uses `notifyStep()` for prepare/authorize/finalize/store phases, observable via `smartAcme.certIssuanceEvents`.
Key implementation details:
- A single reusable `Task` named `cert-issuance` handles all domains via `triggerTaskConstrained()` with different inputs.
- The `shouldExecute` callback on the domain mutex checks the certmanager cache as a safety net.
- `TaskManager.start()` is called in `SmartAcme.start()` and `TaskManager.stop()` in `SmartAcme.stop()`.
- The "no cronjobs specified" log messages during tests come from taskbuffer's internal CronManager polling — harmless noise when no cron tasks are scheduled.
## Dependency Notes ## Dependency Notes
- `acme-client` was replaced with custom implementation in `ts/acme/` + `@peculiar/x509` for CSR generation - `acme-client` was replaced with custom implementation in `ts/acme/` + `@peculiar/x509` for CSR generation

197
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartacme # @push.rocks/smartacme
A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power. 🔒 A TypeScript-based ACME client for Let's Encrypt certificate management with a focus on simplicity and power. 🔒
## Issue Reporting and Security ## Issue Reporting and Security
@@ -16,9 +16,9 @@ Ensure your project uses TypeScript and ECMAScript Modules (ESM).
## Usage ## Usage
`@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). `@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), structured error handling with smart retry logic, and built-in concurrency control with rate limiting to keep you safely within Let's Encrypt limits.
### Quick Start ### 🚀 Quick Start
```typescript ```typescript
import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme'; import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme';
@@ -47,39 +47,44 @@ await smartAcme.start();
// 4. Get a certificate // 4. Get a certificate
const cert = await smartAcme.getCertificateForDomain('example.com'); const cert = await smartAcme.getCertificateForDomain('example.com');
console.log(cert.publicKey); // PEM certificate console.log(cert.publicKey); // PEM certificate chain
console.log(cert.privateKey); // PEM private key console.log(cert.privateKey); // PEM private key
// 5. Clean up // 5. Clean up
await smartAcme.stop(); await smartAcme.stop();
``` ```
### SmartAcme Options ### ⚙️ SmartAcme Options
```typescript ```typescript
interface ISmartAcmeOptions { interface ISmartAcmeOptions {
accountEmail: string; // ACME account email accountEmail: string; // ACME account email
accountPrivateKey?: string; // Optional account key (auto-generated if omitted) accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
certManager: ICertManager; // Certificate storage backend certManager: ICertManager; // Certificate storage backend
environment: 'production' | 'integration'; // LetsEncrypt environment environment: 'production' | 'integration'; // Let's Encrypt environment
challengeHandlers: IChallengeHandler[]; // At least one handler required challengeHandlers: IChallengeHandler[]; // At least one handler required
challengePriority?: string[]; // e.g. ['dns-01', 'http-01'] challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
retryOptions?: { // Optional retry/backoff config retryOptions?: { // Optional retry/backoff config
retries?: number; retries?: number; // Default: 10
factor?: number; factor?: number; // Default: 4
minTimeoutMs?: number; minTimeoutMs?: number; // Default: 1000
maxTimeoutMs?: number; maxTimeoutMs?: number; // Default: 60000
}; };
// Concurrency & rate limiting
maxConcurrentIssuances?: number; // Global cap on parallel ACME ops (default: 5)
maxOrdersPerWindow?: number; // Max orders in sliding window (default: 250)
orderWindowMs?: number; // Sliding window duration in ms (default: 3 hours)
} }
``` ```
### Getting Certificates ### 📜 Getting Certificates
```typescript ```typescript
// Standard certificate for a single domain // Standard certificate for a single domain
const cert = await smartAcme.getCertificateForDomain('example.com'); const cert = await smartAcme.getCertificateForDomain('example.com');
// Include wildcard certificate (requires DNS-01 handler) // Include wildcard coverage (requires DNS-01 handler)
// Issues a single cert covering example.com AND *.example.com
const certWithWildcard = await smartAcme.getCertificateForDomain('example.com', { const certWithWildcard = await smartAcme.getCertificateForDomain('example.com', {
includeWildcard: true, includeWildcard: true,
}); });
@@ -90,25 +95,98 @@ const wildcardCert = await smartAcme.getCertificateForDomain('*.example.com');
Certificates are automatically cached and reused when still valid. Renewal happens automatically when a certificate is within 10 days of expiration. Certificates are automatically cached and reused when still valid. Renewal happens automatically when a certificate is within 10 days of expiration.
### Certificate Object ### 📦 Certificate Object
The returned `SmartacmeCert` object has these properties: The returned `SmartacmeCert` (also exported as `Cert`) object has these properties:
| Property | Type | Description | | Property | Type | Description |
|-------------|----------|--------------------------------------| |-------------|----------|--------------------------------------|
| `id` | `string` | Unique certificate identifier | | `id` | `string` | Unique certificate identifier |
| `domainName`| `string` | Domain the cert is issued for | | `domainName`| `string` | Domain the cert is issued for |
| `publicKey` | `string` | PEM-encoded certificate | | `publicKey` | `string` | PEM-encoded certificate chain |
| `privateKey`| `string` | PEM-encoded private key | | `privateKey`| `string` | PEM-encoded private key |
| `csr` | `string` | Certificate Signing Request | | `csr` | `string` | Certificate Signing Request |
| `created` | `number` | Timestamp of creation | | `created` | `number` | Timestamp of creation |
| `validUntil`| `number` | Timestamp of expiration | | `validUntil`| `number` | Timestamp of expiration |
Useful methods:
```typescript
cert.isStillValid(); // true if not expired
cert.shouldBeRenewed(); // true if expires within 10 days
```
## 🔀 Concurrency Control & Rate Limiting
When many callers request certificates concurrently (e.g., hundreds of subdomains under the same TLD), SmartAcme automatically handles deduplication, concurrency, and rate limiting using a built-in task manager powered by `@push.rocks/taskbuffer`.
### How It Works
Three constraint layers protect your ACME account:
| Layer | What It Does | Default |
|-------|-------------|---------|
| **Per-domain mutex** | Only one issuance runs per base domain at a time. Concurrent requests for the same domain automatically wait and receive the same certificate result. | 1 concurrent per domain |
| **Global concurrency cap** | Limits total parallel ACME operations across all domains. | 5 concurrent |
| **Account rate limit** | Sliding-window rate limiter that keeps you under Let's Encrypt's 300 orders/3h account limit. | 250 per 3 hours |
### 🛡️ Automatic Request Deduplication
If 100 requests come in for subdomains of `example.com` simultaneously, only **one** ACME issuance runs. All other callers automatically wait and receive the same certificate — no duplicate orders, no wasted rate limit budget.
```typescript
// These all resolve to the same certificate with a single ACME order:
const results = await Promise.all([
smartAcme.getCertificateForDomain('app.example.com'),
smartAcme.getCertificateForDomain('api.example.com'),
smartAcme.getCertificateForDomain('cdn.example.com'),
]);
```
### ⚡ Configuring Limits
```typescript
const smartAcme = new SmartAcme({
accountEmail: 'admin@example.com',
certManager,
environment: 'production',
challengeHandlers: [dnsHandler],
maxConcurrentIssuances: 10, // Allow up to 10 parallel ACME issuances
maxOrdersPerWindow: 200, // Cap at 200 orders per window
orderWindowMs: 2 * 60 * 60_000, // 2-hour sliding window
});
```
### 📊 Observing Issuance Progress
Subscribe to the `certIssuanceEvents` stream to observe certificate issuance progress in real-time:
```typescript
smartAcme.certIssuanceEvents.subscribe((event) => {
switch (event.type) {
case 'started':
console.log(`🔄 Issuance started: ${event.task.name}`);
break;
case 'step':
console.log(`📍 Step: ${event.stepName} (${event.task.currentProgress}%)`);
break;
case 'completed':
console.log(`✅ Issuance completed: ${event.task.name}`);
break;
case 'failed':
console.log(`❌ Issuance failed: ${event.error}`);
break;
}
});
```
Each issuance goes through four steps: **prepare** (10%) → **authorize** (40%) → **finalize** (30%) → **store** (20%).
## Certificate Managers ## Certificate Managers
SmartAcme uses the `ICertManager` interface for pluggable certificate storage. SmartAcme uses the `ICertManager` interface for pluggable certificate storage.
### MongoCertManager ### 🗄️ MongoCertManager
Persistent storage backed by MongoDB using `@push.rocks/smartdata`: Persistent storage backed by MongoDB using `@push.rocks/smartdata`:
@@ -122,7 +200,7 @@ const certManager = new certmanagers.MongoCertManager({
}); });
``` ```
### MemoryCertManager ### 🧪 MemoryCertManager
In-memory storage, ideal for testing or ephemeral workloads: In-memory storage, ideal for testing or ephemeral workloads:
@@ -132,13 +210,12 @@ import { certmanagers } from '@push.rocks/smartacme';
const certManager = new certmanagers.MemoryCertManager(); const certManager = new certmanagers.MemoryCertManager();
``` ```
### Custom Certificate Manager ### 🔧 Custom Certificate Manager
Implement the `ICertManager` interface for your own storage backend: Implement the `ICertManager` interface for your own storage backend:
```typescript ```typescript
import type { ICertManager } from '@push.rocks/smartacme'; import type { ICertManager, Cert } from '@push.rocks/smartacme';
import { Cert } from '@push.rocks/smartacme';
class RedisCertManager implements ICertManager { class RedisCertManager implements ICertManager {
async init(): Promise<void> { /* connect */ } async init(): Promise<void> { /* connect */ }
@@ -166,7 +243,7 @@ const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
const dnsHandler = new handlers.Dns01Handler(cfAccount); const dnsHandler = new handlers.Dns01Handler(cfAccount);
``` ```
DNS-01 is required for wildcard certificates and works regardless of server accessibility. DNS-01 is **required** for wildcard certificates and works regardless of server accessibility.
### 📁 Http01Webroot ### 📁 Http01Webroot
@@ -197,12 +274,12 @@ app.use((req, res, next) => memHandler.handleRequest(req, res, next));
Perfect for serverless or container environments where filesystem access is limited. Perfect for serverless or container environments where filesystem access is limited.
### Custom Challenge Handler ### 🔧 Custom Challenge Handler
Implement `IChallengeHandler<T>` for custom challenge types: Implement `IChallengeHandler<T>` for custom challenge types:
```typescript ```typescript
import type { IChallengeHandler } from '@push.rocks/smartacme'; import type { handlers } from '@push.rocks/smartacme';
interface MyChallenge { interface MyChallenge {
type: string; type: string;
@@ -210,28 +287,54 @@ interface MyChallenge {
keyAuthorization: string; keyAuthorization: string;
} }
class MyHandler implements IChallengeHandler<MyChallenge> { class MyHandler implements handlers.IChallengeHandler<MyChallenge> {
getSupportedTypes(): string[] { return ['http-01']; } getSupportedTypes(): string[] { return ['http-01']; }
async prepare(ch: MyChallenge): Promise<void> { /* ... */ } async prepare(ch: MyChallenge): Promise<void> { /* set up challenge response */ }
async cleanup(ch: MyChallenge): Promise<void> { /* ... */ } async cleanup(ch: MyChallenge): Promise<void> { /* tear down */ }
async checkWetherDomainIsSupported(domain: string): Promise<boolean> { return true; } async checkWetherDomainIsSupported(domain: string): Promise<boolean> { return true; }
} }
``` ```
## Error Handling
SmartAcme provides structured ACME error handling via the `AcmeError` class, which carries full RFC 8555 error information:
```typescript
import { AcmeError } from '@push.rocks/smartacme/ts/acme/acme.classes.error.js';
try {
const cert = await smartAcme.getCertificateForDomain('example.com');
} catch (err) {
if (err instanceof AcmeError) {
console.log(err.status); // HTTP status code (e.g. 429)
console.log(err.type); // ACME error URN (e.g. 'urn:ietf:params:acme:error:rateLimited')
console.log(err.detail); // Human-readable message
console.log(err.subproblems); // Per-identifier sub-errors (RFC 8555 §6.7.1)
console.log(err.retryAfter); // Retry-After value in seconds
console.log(err.isRateLimited); // true for 429 or rateLimited type
console.log(err.isRetryable); // true for 429, 503, 5xx, badNonce; false for 403/404/409
}
}
```
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.
## Domain Matching ## Domain Matching
SmartAcme automatically maps subdomains to their base domain for certificate lookups: SmartAcme automatically maps subdomains to their base domain for certificate lookups:
```typescript ```
// subdomain.example.com → certificate for example.com subdomain.example.com → certificate for example.com
// *.example.com → certificate for example.com *.example.com → certificate for example.com
// a.b.example.com → not supported (4+ level domains) a.b.example.com → not supported (4+ levels) ❌
``` ```
## Environment ## Environment
- **`production`** — Uses LetsEncrypt production servers. Rate limits apply. | Environment | Description |
- **`integration`** — Uses LetsEncrypt staging servers. No rate limits, but certificates are not trusted by browsers. Use for testing. |----------------|-------------|
| `production` | Let's Encrypt production servers. Certificates are browser-trusted. [Rate limits](https://letsencrypt.org/docs/rate-limits/) apply. |
| `integration` | Let's Encrypt staging servers. No rate limits, but certificates are **not** browser-trusted. Use for testing. |
## Complete Example with HTTP-01 ## Complete Example with HTTP-01
@@ -269,13 +372,21 @@ await smartAcme.stop();
server.close(); server.close();
``` ```
## Testing ## Architecture
```bash Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules:
pnpm test
```
Tests use `@git.zone/tstest` with the tapbundle assertion library. | Module | Purpose |
|--------|---------|
| `AcmeClient` | Top-level ACME facade — orders, authorizations, finalization |
| `AcmeCrypto` | RSA key generation, JWK/JWS (RFC 7515/7638), CSR via `@peculiar/x509` |
| `AcmeHttpClient` | JWS-signed HTTP transport with nonce management and structured logging |
| `AcmeError` | Structured error class with type URN, subproblems, Retry-After, retryability |
| `AcmeOrderManager` | Order lifecycle — create, poll, finalize, download certificate |
| `AcmeChallengeManager` | Key authorization computation and challenge completion |
| `TaskManager` | Constraint-based concurrency control, rate limiting, and request deduplication via `@push.rocks/taskbuffer` |
All cryptographic operations use `node:crypto`. The only external crypto dependency is `@peculiar/x509` for CSR generation.
## License and Legal Information ## License and Legal Information

View File

@@ -25,16 +25,12 @@ tap.test('HTTP-01 only configuration should work for regular domains', async ()
smartAcmeInstance.certmatcher = { smartAcmeInstance.certmatcher = {
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '') getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
} as any; } as any;
smartAcmeInstance.interestMap = {
checkInterest: async () => false,
addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any)
} as any;
await smartAcmeInstance.certmanager.init(); await smartAcmeInstance.certmanager.init();
}; };
await smartAcmeInstance.start(); await smartAcmeInstance.start();
// Stub the core certificate methods to avoid actual ACME calls // Stub the core certificate methods to avoid actual ACME calls
smartAcmeInstance.client = { (smartAcmeInstance as any).client = {
createOrder: async (orderPayload: any) => { createOrder: async (orderPayload: any) => {
// Verify no wildcard is included in default request // Verify no wildcard is included in default request
const identifiers = orderPayload.identifiers; const identifiers = orderPayload.identifiers;
@@ -47,8 +43,8 @@ tap.test('HTTP-01 only configuration should work for regular domains', async ()
finalizeOrder: async () => {}, finalizeOrder: async () => {},
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any; } as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn(); (smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => fn();
// Mock certmanager methods // Mock certmanager methods
smartAcmeInstance.certmanager.retrieveCertificate = async () => null; smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
@@ -83,16 +79,12 @@ tap.test('should only include wildcard when explicitly requested with DNS-01', a
smartAcmeInstance.certmatcher = { smartAcmeInstance.certmatcher = {
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '') getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
} as any; } as any;
smartAcmeInstance.interestMap = {
checkInterest: async () => false,
addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any)
} as any;
await smartAcmeInstance.certmanager.init(); await smartAcmeInstance.certmanager.init();
}; };
await smartAcmeInstance.start(); await smartAcmeInstance.start();
// Stub the core certificate methods // Stub the core certificate methods
smartAcmeInstance.client = { (smartAcmeInstance as any).client = {
createOrder: async (orderPayload: any) => { createOrder: async (orderPayload: any) => {
const identifiers = orderPayload.identifiers; const identifiers = orderPayload.identifiers;
expect(identifiers.length).toEqual(2); expect(identifiers.length).toEqual(2);
@@ -104,8 +96,8 @@ tap.test('should only include wildcard when explicitly requested with DNS-01', a
finalizeOrder: async () => {}, finalizeOrder: async () => {},
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any; } as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn(); (smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => fn();
// Mock certmanager methods // Mock certmanager methods
smartAcmeInstance.certmanager.retrieveCertificate = async () => null; smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
@@ -136,14 +128,10 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as
smartAcmeInstance.certmatcher = { smartAcmeInstance.certmatcher = {
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '') getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
} as any; } as any;
smartAcmeInstance.interestMap = {
checkInterest: async () => false,
addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any)
} as any;
await smartAcmeInstance.certmanager.init(); await smartAcmeInstance.certmanager.init();
}; };
await smartAcmeInstance.start(); await smartAcmeInstance.start();
// Mock logger to capture warning // Mock logger to capture warning
const logSpy = { called: false, message: '' }; const logSpy = { called: false, message: '' };
smartAcmeInstance.logger.log = async (level: string, message: string) => { smartAcmeInstance.logger.log = async (level: string, message: string) => {
@@ -152,9 +140,9 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as
logSpy.message = message; logSpy.message = message;
} }
}; };
// Stub the core certificate methods // Stub the core certificate methods
smartAcmeInstance.client = { (smartAcmeInstance as any).client = {
createOrder: async (orderPayload: any) => { createOrder: async (orderPayload: any) => {
const identifiers = orderPayload.identifiers; const identifiers = orderPayload.identifiers;
// Should only have regular domain, no wildcard // Should only have regular domain, no wildcard
@@ -166,8 +154,8 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as
finalizeOrder: async () => {}, finalizeOrder: async () => {},
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any; } as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn(); (smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => fn();
// Mock certmanager methods // Mock certmanager methods
smartAcmeInstance.certmanager.retrieveCertificate = async () => null; smartAcmeInstance.certmanager.retrieveCertificate = async () => null;

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartacme', name: '@push.rocks/smartacme',
version: '9.0.0', version: '9.1.3',
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.' description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
} }

View File

@@ -96,4 +96,11 @@ export class AcmeClient {
async getCertificate(order: IAcmeOrder): Promise<string> { async getCertificate(order: IAcmeOrder): Promise<string> {
return this.orderManager.getCertificate(order); return this.orderManager.getCertificate(order);
} }
/**
* Destroy HTTP transport to release sockets and allow process exit.
*/
destroy(): void {
this.httpClient.destroy();
}
} }

View File

@@ -17,11 +17,23 @@ export class AcmeHttpClient {
private nonce: string | null = null; private nonce: string | null = null;
public kid: string | null = null; public kid: string | null = null;
private logger?: TAcmeLogger; private logger?: TAcmeLogger;
private httpsAgent: https.Agent;
private httpAgent: http.Agent;
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) { constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
this.directoryUrl = directoryUrl; this.directoryUrl = directoryUrl;
this.accountKeyPem = accountKeyPem; this.accountKeyPem = accountKeyPem;
this.logger = logger; this.logger = logger;
this.httpsAgent = new https.Agent({ keepAlive: false });
this.httpAgent = new http.Agent({ keepAlive: false });
}
/**
* Destroy HTTP agents to release sockets and allow process exit.
*/
destroy(): void {
this.httpsAgent.destroy();
this.httpAgent.destroy();
} }
private log(level: string, message: string, data?: any): void { private log(level: string, message: string, data?: any): void {
@@ -186,6 +198,7 @@ export class AcmeHttpClient {
path: urlObj.pathname + urlObj.search, path: urlObj.pathname + urlObj.search,
method, method,
headers: requestHeaders, headers: requestHeaders,
agent: isHttps ? this.httpsAgent : this.httpAgent,
}; };
const req = lib.request(options, (res) => { const req = lib.request(options, (res) => {

View File

@@ -9,3 +9,6 @@ export { certmanagers };
// handlers // handlers
import * as handlers from './handlers/index.js'; import * as handlers from './handlers/index.js';
export { handlers }; export { handlers };
// re-export taskbuffer event types for consumers
export type { ITaskEvent, ITaskMetadata } from '@push.rocks/taskbuffer';

View File

@@ -19,6 +19,7 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartunique from '@push.rocks/smartunique'; import * as smartunique from '@push.rocks/smartunique';
import * as smartstring from '@push.rocks/smartstring'; import * as smartstring from '@push.rocks/smartstring';
import * as smarttime from '@push.rocks/smarttime'; import * as smarttime from '@push.rocks/smarttime';
import * as taskbuffer from '@push.rocks/taskbuffer';
export { export {
lik, lik,
@@ -30,6 +31,7 @@ export {
smartunique, smartunique,
smartstring, smartstring,
smarttime, smarttime,
taskbuffer,
}; };
// @tsclass scope // @tsclass scope

View File

@@ -4,6 +4,22 @@ import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js';
import { commitinfo } from './00_commitinfo_data.js'; import { commitinfo } from './00_commitinfo_data.js';
import { SmartacmeCert } from './smartacme.classes.cert.js'; import { SmartacmeCert } from './smartacme.classes.cert.js';
// ── Types & constants for certificate issuance task ──────────────────────────
interface ICertIssuanceInput {
certDomainName: string;
domainArg: string;
isWildcardRequest: boolean;
includeWildcard: boolean;
}
const CERT_ISSUANCE_STEPS = [
{ name: 'prepare', description: 'Creating ACME order', percentage: 10 },
{ name: 'authorize', description: 'Solving ACME challenges', percentage: 40 },
{ name: 'finalize', description: 'Finalizing and getting cert', percentage: 30 },
{ name: 'store', description: 'Storing certificate', percentage: 20 },
] as const;
/** /**
* the options for the class @see SmartAcme * the options for the class @see SmartAcme
*/ */
@@ -38,6 +54,21 @@ export interface ISmartAcmeOptions {
* Defaults to ['dns-01'] or first supported type from handlers. * Defaults to ['dns-01'] or first supported type from handlers.
*/ */
challengePriority?: string[]; challengePriority?: string[];
/**
* Maximum number of concurrent ACME issuances across all domains.
* Defaults to 5.
*/
maxConcurrentIssuances?: number;
/**
* Maximum ACME orders allowed within the sliding window.
* Defaults to 250 (conservative limit under Let's Encrypt's 300/3h).
*/
maxOrdersPerWindow?: number;
/**
* Sliding window duration in milliseconds for rate limiting.
* Defaults to 3 hours (10_800_000 ms).
*/
orderWindowMs?: number;
} }
/** /**
@@ -75,12 +106,21 @@ export class SmartAcme {
private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = []; private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = [];
// priority order of challenge types // priority order of challenge types
private challengePriority: string[]; private challengePriority: string[];
// Map for coordinating concurrent certificate requests // TaskManager for coordinating concurrent certificate requests
private interestMap: plugins.lik.InterestMap<string, SmartacmeCert>; private taskManager: plugins.taskbuffer.TaskManager;
// Single reusable task for certificate issuance
private certIssuanceTask: plugins.taskbuffer.Task<undefined, typeof CERT_ISSUANCE_STEPS>;
// bound signal handlers so they can be removed on stop() // bound signal handlers so they can be removed on stop()
private boundSigintHandler: (() => void) | null = null; private boundSigintHandler: (() => void) | null = null;
private boundSigtermHandler: (() => void) | null = null; private boundSigtermHandler: (() => void) | null = null;
/**
* Exposes the aggregated task event stream for observing certificate issuance progress.
*/
public get certIssuanceEvents(): plugins.taskbuffer.TaskManager['taskSubject'] {
return this.taskManager.taskSubject;
}
constructor(optionsArg: ISmartAcmeOptions) { constructor(optionsArg: ISmartAcmeOptions) {
this.options = optionsArg; this.options = optionsArg;
this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo); this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo);
@@ -105,8 +145,60 @@ export class SmartAcme {
optionsArg.challengePriority && optionsArg.challengePriority.length > 0 optionsArg.challengePriority && optionsArg.challengePriority.length > 0
? optionsArg.challengePriority ? optionsArg.challengePriority
: this.challengeHandlers.map((h) => h.getSupportedTypes()[0]); : this.challengeHandlers.map((h) => h.getSupportedTypes()[0]);
// initialize interest coordination
this.interestMap = new plugins.lik.InterestMap((domain) => domain); // ── TaskManager setup ──────────────────────────────────────────────────
this.taskManager = new plugins.taskbuffer.TaskManager();
// Constraint 1: Per-domain mutex — one issuance at a time per TLD, with result sharing
const certDomainMutex = new plugins.taskbuffer.TaskConstraintGroup({
name: 'cert-domain-mutex',
maxConcurrent: 1,
resultSharingMode: 'share-latest',
constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => {
return input?.certDomainName ?? null;
},
shouldExecute: async (_task, input?: ICertIssuanceInput) => {
if (!input?.certDomainName || !this.certmanager) return true;
// Safety net: if a valid cert is already cached, skip re-issuance
const existing = await this.certmanager.retrieveCertificate(input.certDomainName);
if (existing && !existing.shouldBeRenewed()) {
return false;
}
return true;
},
});
// Constraint 2: Global concurrency cap
const acmeGlobalConcurrency = new plugins.taskbuffer.TaskConstraintGroup({
name: 'acme-global-concurrency',
maxConcurrent: optionsArg.maxConcurrentIssuances ?? 5,
constraintKeyForExecution: () => 'global',
});
// Constraint 3: Account-level rate limiting
const acmeAccountRateLimit = new plugins.taskbuffer.TaskConstraintGroup({
name: 'acme-account-rate-limit',
rateLimit: {
maxPerWindow: optionsArg.maxOrdersPerWindow ?? 250,
windowMs: optionsArg.orderWindowMs ?? 10_800_000,
},
constraintKeyForExecution: () => 'account',
});
this.taskManager.addConstraintGroup(certDomainMutex);
this.taskManager.addConstraintGroup(acmeGlobalConcurrency);
this.taskManager.addConstraintGroup(acmeAccountRateLimit);
// Create the single reusable certificate issuance task
this.certIssuanceTask = new plugins.taskbuffer.Task({
name: 'cert-issuance',
steps: CERT_ISSUANCE_STEPS,
taskFunction: async (input: ICertIssuanceInput) => {
return this.performCertificateIssuance(input);
},
});
this.taskManager.addTask(this.certIssuanceTask);
} }
/** /**
@@ -149,6 +241,10 @@ export class SmartAcme {
termsOfServiceAgreed: true, termsOfServiceAgreed: true,
contact: [`mailto:${this.options.accountEmail}`], contact: [`mailto:${this.options.accountEmail}`],
}); });
// Start the task manager
await this.taskManager.start();
// Setup graceful shutdown handlers (store references for removal in stop()) // Setup graceful shutdown handlers (store references for removal in stop())
this.boundSigintHandler = () => this.handleSignal('SIGINT'); this.boundSigintHandler = () => this.handleSignal('SIGINT');
this.boundSigtermHandler = () => this.handleSignal('SIGTERM'); this.boundSigtermHandler = () => this.handleSignal('SIGTERM');
@@ -169,6 +265,16 @@ export class SmartAcme {
process.removeListener('SIGTERM', this.boundSigtermHandler); process.removeListener('SIGTERM', this.boundSigtermHandler);
this.boundSigtermHandler = null; this.boundSigtermHandler = null;
} }
// Stop the task manager
await this.taskManager.stop();
// Destroy ACME HTTP transport (closes keep-alive sockets)
if (this.client) {
this.client.destroy();
}
// Destroy DNS client (kills Rust bridge child process if spawned)
if (this.smartdns) {
this.smartdns.destroy();
}
if (this.certmanager && typeof (this.certmanager as any).close === 'function') { if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
await (this.certmanager as any).close(); await (this.certmanager as any).close();
} }
@@ -247,8 +353,7 @@ export class SmartAcme {
* * if not in the database announce it * * if not in the database announce it
* * then get it from letsencrypt * * then get it from letsencrypt
* * store it * * store it
* * remove it from the pending map (which it go onto by announcing it) * * retrieve it from the database and return it
* * retrieve it from the databse and return it
* *
* @param domainArg * @param domainArg
* @param options Optional configuration for certificate generation * @param options Optional configuration for certificate generation
@@ -276,35 +381,60 @@ export class SmartAcme {
// Retrieve any existing certificate record by base domain. // Retrieve any existing certificate record by base domain.
const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName); const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName);
if ( if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
!retrievedCertificate &&
(await this.interestMap.checkInterest(certDomainName))
) {
const existingCertificateInterest = this.interestMap.findInterest(certDomainName);
const certificate = existingCertificateInterest.interestFullfilled;
return certificate;
} else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
return retrievedCertificate; return retrievedCertificate;
} else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) { } else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) {
// Remove old certificate via certManager // Remove old certificate via certManager
await this.certmanager.deleteCertificate(certDomainName); await this.certmanager.deleteCertificate(certDomainName);
} }
// lets make sure others get the same interest // Build issuance input and trigger the constrained task
const currentDomainInterst = await this.interestMap.addInterest(certDomainName); const issuanceInput: ICertIssuanceInput = {
certDomainName,
domainArg,
isWildcardRequest,
includeWildcard: options?.includeWildcard ?? false,
};
const result = await this.taskManager.triggerTaskConstrained(
this.certIssuanceTask,
issuanceInput,
);
// If we got a cert directly (either from execution or result sharing), return it
if (result != null) {
return result;
}
// If shouldExecute returned false (cert appeared in cache), read from cache
const cachedCert = await this.certmanager.retrieveCertificate(certDomainName);
if (cachedCert) {
return cachedCert;
}
throw new Error(`Certificate issuance failed for ${certDomainName}`);
}
/**
* Performs the actual ACME certificate issuance flow.
* Called by the certIssuanceTask's taskFunction.
*/
private async performCertificateIssuance(input: ICertIssuanceInput): Promise<SmartacmeCert> {
const { certDomainName, isWildcardRequest, includeWildcard } = input;
// ── Step: prepare ─────────────────────────────────────────────────────
this.certIssuanceTask.notifyStep('prepare');
// Build identifiers array based on request // Build identifiers array based on request
const identifiers = []; const identifiers: Array<{ type: 'dns'; value: string }> = [];
if (isWildcardRequest) { if (isWildcardRequest) {
// If requesting a wildcard directly, only add the wildcard
identifiers.push({ type: 'dns', value: `*.${certDomainName}` }); identifiers.push({ type: 'dns', value: `*.${certDomainName}` });
} else {
// Add the regular domain
identifiers.push({ type: 'dns', value: certDomainName }); identifiers.push({ type: 'dns', value: certDomainName });
} else {
// Only add wildcard if explicitly requested identifiers.push({ type: 'dns', value: certDomainName });
if (options?.includeWildcard) {
if (includeWildcard) {
const hasDnsHandler = this.challengeHandlers.some((h) => const hasDnsHandler = this.challengeHandlers.some((h) =>
h.getSupportedTypes().includes('dns-01'), h.getSupportedTypes().includes('dns-01'),
); );
@@ -321,6 +451,9 @@ export class SmartAcme {
identifiers, identifiers,
}), 'createOrder'); }), 'createOrder');
// ── Step: authorize ───────────────────────────────────────────────────
this.certIssuanceTask.notifyStep('authorize');
/* Get authorizations and select challenges */ /* Get authorizations and select challenges */
const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations'); const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations');
@@ -344,45 +477,37 @@ export class SmartAcme {
} }
const { type, handler } = selectedHandler; const { type, handler } = selectedHandler;
// build handler input with keyAuthorization // build handler input with keyAuthorization
let input: any; let challengeInput: any;
// retrieve keyAuthorization for challenge // retrieve keyAuthorization for challenge
const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg); const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg);
if (type === 'dns-01') { if (type === 'dns-01') {
input = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth }; challengeInput = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
} else if (type === 'http-01') { } else if (type === 'http-01') {
// HTTP-01 requires serving token at webPath challengeInput = {
input = {
type, type,
token: (selectedChallengeArg as any).token, token: (selectedChallengeArg as any).token,
keyAuthorization: keyAuth, keyAuthorization: keyAuth,
webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`, webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`,
}; };
} else { } else {
// generic challenge input: include raw challenge properties challengeInput = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
} }
this.pendingChallenges.push(input); this.pendingChallenges.push(challengeInput);
try { try {
// Prepare the challenge (set DNS record, write file, etc.) await this.retry(() => handler.prepare(challengeInput), `${type}.prepare`);
await this.retry(() => handler.prepare(input), `${type}.prepare`);
// For DNS-01, wait for propagation before verification
if (type === 'dns-01') { if (type === 'dns-01') {
const dnsInput = input as { hostName: string; challenge: string }; const dnsInput = challengeInput as { hostName: string; challenge: string };
// Wait for authoritative DNS propagation before ACME verify
await this.retry( await this.retry(
() => this.smartdns.checkUntilAvailable(dnsInput.hostName, 'TXT', dnsInput.challenge, 100, 5000), () => this.smartdns.checkUntilAvailable(dnsInput.hostName, 'TXT', dnsInput.challenge, 100, 5000),
`${type}.propagation`, `${type}.propagation`,
); );
// Extra cool-down to ensure ACME server sees the new TXT record
this.logger.log('info', 'Cooling down for 1 minute before ACME verification'); this.logger.log('info', 'Cooling down for 1 minute before ACME verification');
await plugins.smartdelay.delayFor(60000); await plugins.smartdelay.delayFor(60000);
} }
// Notify ACME server to complete the challenge
await this.retry( await this.retry(
() => this.client.completeChallenge(selectedChallengeArg), () => this.client.completeChallenge(selectedChallengeArg),
`${type}.completeChallenge`, `${type}.completeChallenge`,
); );
// Wait for valid status (warnings on staging timeouts)
try { try {
await this.retry( await this.retry(
() => this.client.waitForValidStatus(selectedChallengeArg), () => this.client.waitForValidStatus(selectedChallengeArg),
@@ -396,34 +521,32 @@ export class SmartAcme {
); );
} }
} finally { } finally {
// Always cleanup resource
try { try {
await this.retry(() => handler.cleanup(input), `${type}.cleanup`); await this.retry(() => handler.cleanup(challengeInput), `${type}.cleanup`);
} catch (err) { } catch (err) {
await this.logger.log('error', `Error during ${type}.cleanup`, err); await this.logger.log('error', `Error during ${type}.cleanup`, err);
} finally { } finally {
this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input); this.pendingChallenges = this.pendingChallenges.filter((c) => c !== challengeInput);
} }
} }
} }
/* Finalize order */ // ── Step: finalize ────────────────────────────────────────────────────
const csrDomains = []; this.certIssuanceTask.notifyStep('finalize');
const csrDomains: string[] = [];
let commonName: string; let commonName: string;
if (isWildcardRequest) { if (isWildcardRequest) {
// For wildcard requests, use wildcard as common name
commonName = `*.${certDomainName}`; commonName = `*.${certDomainName}`;
csrDomains.push(certDomainName); // Add base domain as alt name csrDomains.push(certDomainName);
} else { } else {
// For regular requests, use base domain as common name
commonName = certDomainName; commonName = certDomainName;
if (options?.includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) { if (includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) {
// If wildcard was successfully added, include it as alt name
csrDomains.push(`*.${certDomainName}`); csrDomains.push(`*.${certDomainName}`);
} }
} }
const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({ const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({
commonName, commonName,
altNames: csrDomains, altNames: csrDomains,
@@ -432,9 +555,9 @@ export class SmartAcme {
await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder'); await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder');
const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate'); const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate');
/* Done */ // ── Step: store ───────────────────────────────────────────────────────
this.certIssuanceTask.notifyStep('store');
// Store the new certificate record
const certRecord = new SmartacmeCert({ const certRecord = new SmartacmeCert({
id: plugins.smartunique.shortId(), id: plugins.smartunique.shortId(),
domainName: certDomainName, domainName: certDomainName,
@@ -447,9 +570,7 @@ export class SmartAcme {
await this.certmanager.storeCertificate(certRecord); await this.certmanager.storeCertificate(certRecord);
const newCertificate = await this.certmanager.retrieveCertificate(certDomainName); const newCertificate = await this.certmanager.retrieveCertificate(certDomainName);
currentDomainInterst.fullfillInterest(newCertificate); return newCertificate ?? certRecord;
currentDomainInterst.destroy();
return newCertificate;
} }
} }