@push.rocks/smartacme 9.4.0 → 9.6.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.
@@ -3,6 +3,12 @@ import type { ICertManager } from './interfaces/certmanager.js';
3
3
  import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js';
4
4
  import { commitinfo } from './00_commitinfo_data.js';
5
5
  import { SmartacmeCert } from './smartacme.classes.cert.js';
6
+ import { ExactCertificateIssuer } from './classes.exact-certificate-issuer.js';
7
+ import type {
8
+ IExactCertificateIdentity,
9
+ IExactCertificateRequest, IExactIssuanceRecoveryRequest, IExactIssuanceStore,
10
+ IExactIssuanceInfo, TExactCertificateResult,
11
+ } from './interfaces/exact-issuance.js';
6
12
 
7
13
  // ── Types & constants for certificate issuance task ──────────────────────────
8
14
 
@@ -11,6 +17,8 @@ interface ICertIssuanceInput {
11
17
  domainArg: string;
12
18
  isWildcardRequest: boolean;
13
19
  includeWildcard: boolean;
20
+ forceRenew: boolean;
21
+ exact?: { request: IExactCertificateRequest; recovery?: IExactIssuanceRecoveryRequest };
14
22
  }
15
23
 
16
24
  const CERT_ISSUANCE_STEPS = [
@@ -32,6 +40,14 @@ export interface ISmartAcmeOptions {
32
40
  certManager: ICertManager;
33
41
  // Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers`
34
42
  environment: 'production' | 'integration';
43
+ /** Optional explicit ACME directory, for private CAs and isolated integration. */
44
+ directoryUrl?: string;
45
+ /** Enables isolated explicit-identifier issuance with durable recovery. */
46
+ exactIssuanceStore?: IExactIssuanceStore;
47
+ /** Bounded number of admitted exact-identity operations (default 64). */
48
+ maxPendingExactRequests?: number;
49
+ /** DNS propagation grace period for explicit issuance; defaults to 60 seconds. */
50
+ exactDnsPropagationDelayMs?: number;
35
51
  /**
36
52
  * Optional retry/backoff configuration for transient failures
37
53
  */
@@ -91,6 +107,10 @@ export class SmartAcme {
91
107
 
92
108
  // the account private key
93
109
  private privateKey!: string;
110
+ private exactIssuer?: ExactCertificateIssuer;
111
+ private exactRequests = new Map<string, Promise<TExactCertificateResult>>();
112
+ private lifecycleAbort = new AbortController();
113
+ private started = false;
94
114
 
95
115
 
96
116
  // certificate manager for persistence (implements ICertManager)
@@ -123,6 +143,12 @@ export class SmartAcme {
123
143
 
124
144
  constructor(optionsArg: ISmartAcmeOptions) {
125
145
  this.options = optionsArg;
146
+ if (!Number.isSafeInteger(optionsArg.maxPendingExactRequests ?? 64)
147
+ || (optionsArg.maxPendingExactRequests ?? 64) < 1
148
+ || !Number.isSafeInteger(optionsArg.exactDnsPropagationDelayMs ?? 60000)
149
+ || (optionsArg.exactDnsPropagationDelayMs ?? 60000) < 0) {
150
+ throw new Error('Invalid explicit issuance admission or propagation limits');
151
+ }
126
152
  this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo);
127
153
  // enable console output for structured logging
128
154
  this.logger.enableConsole();
@@ -155,10 +181,13 @@ export class SmartAcme {
155
181
  maxConcurrent: 1,
156
182
  resultSharingMode: 'share-latest',
157
183
  constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => {
184
+ if (input?.exact) return null;
158
185
  return input?.certDomainName ?? null;
159
186
  },
160
187
  shouldExecute: async (_task, input?: ICertIssuanceInput) => {
188
+ if (input?.exact) return true;
161
189
  if (!input?.certDomainName || !this.certmanager) return true;
190
+ if (input.forceRenew) return true;
162
191
  // Safety net: if a valid cert is already cached, skip re-issuance
163
192
  const existing = await this.certmanager.retrieveCertificate(input.certDomainName);
164
193
  if (existing && !existing.shouldBeRenewed()) {
@@ -186,6 +215,12 @@ export class SmartAcme {
186
215
  });
187
216
 
188
217
  this.taskManager.addConstraintGroup(certDomainMutex);
218
+ // One fixed key reserves a bounded share of global capacity for testing.
219
+ // Exact results contain private keys and must never enter result-sharing caches.
220
+ this.taskManager.addConstraintGroup(new plugins.taskbuffer.TaskConstraintGroup({
221
+ name: 'exact-issuance-concurrency', maxConcurrent: 2,
222
+ constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => input?.exact ? 'exact' : null,
223
+ }));
189
224
  this.taskManager.addConstraintGroup(acmeGlobalConcurrency);
190
225
  this.taskManager.addConstraintGroup(acmeAccountRateLimit);
191
226
 
@@ -194,6 +229,12 @@ export class SmartAcme {
194
229
  name: 'cert-issuance',
195
230
  steps: CERT_ISSUANCE_STEPS,
196
231
  taskFunction: async (input: ICertIssuanceInput) => {
232
+ if (input.exact) {
233
+ const issuer = this.requireExactIssuer();
234
+ return input.exact.recovery
235
+ ? issuer.recover(input.exact.recovery)
236
+ : issuer.run(input.exact.request);
237
+ }
197
238
  return this.performCertificateIssuance(input);
198
239
  },
199
240
  });
@@ -208,8 +249,31 @@ export class SmartAcme {
208
249
  * ```
209
250
  */
210
251
  public async start() {
252
+ if (this.started) throw new Error('SmartAcme is already started');
253
+ this.lifecycleAbort = new AbortController();
211
254
  this.privateKey =
212
255
  this.options.accountPrivateKey || plugins.acme.AcmeCrypto.createRsaPrivateKey();
256
+ const directoryUrl = this.options.directoryUrl ?? (this.options.environment === 'production'
257
+ ? plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production
258
+ : plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging);
259
+ const directory = new URL(directoryUrl);
260
+ if (directory.username || directory.password || directory.hash
261
+ || (directory.protocol !== 'https:' && !(directory.protocol === 'http:'
262
+ && ['localhost', '127.0.0.1', '[::1]'].includes(directory.hostname)))) {
263
+ throw new Error('An HTTPS ACME directory or an explicit loopback test directory is required');
264
+ }
265
+ if (this.options.exactIssuanceStore && !this.options.accountPrivateKey) {
266
+ const accountId = plugins.crypto.createHash('sha256')
267
+ .update(JSON.stringify([directoryUrl, this.options.accountEmail.trim().toLowerCase()])).digest('hex');
268
+ const store = this.options.exactIssuanceStore;
269
+ let storedKey = await store.getAccountKey(accountId);
270
+ if (!storedKey) {
271
+ await store.createAccountKey(accountId, this.privateKey);
272
+ storedKey = await store.getAccountKey(accountId);
273
+ }
274
+ if (!storedKey) throw new Error('ACME account key could not be retained durably');
275
+ this.privateKey = storedKey;
276
+ }
213
277
 
214
278
  // Initialize certificate manager
215
279
  if (!this.options.certManager) {
@@ -223,14 +287,9 @@ export class SmartAcme {
223
287
 
224
288
  // ACME Client
225
289
  this.client = new plugins.acme.AcmeClient({
226
- directoryUrl: (() => {
227
- if (this.options.environment === 'production') {
228
- return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production;
229
- } else {
230
- return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging;
231
- }
232
- })(),
290
+ directoryUrl,
233
291
  accountKeyPem: this.privateKey,
292
+ signal: this.lifecycleAbort.signal,
234
293
  logger: (level, message, data) => {
235
294
  this.logger.log(level as any, message, data);
236
295
  },
@@ -244,6 +303,15 @@ export class SmartAcme {
244
303
 
245
304
  // Start the task manager
246
305
  await this.taskManager.start();
306
+ if (this.options.exactIssuanceStore) {
307
+ this.exactIssuer = new ExactCertificateIssuer({
308
+ store: this.options.exactIssuanceStore, client: this.client, directoryUrl,
309
+ accountThumbprint: plugins.acme.AcmeCrypto.getJwkThumbprint(plugins.acme.AcmeCrypto.getJwk(this.privateKey)),
310
+ dns: this.smartdns, handlers: this.challengeHandlers, signal: this.lifecycleAbort.signal,
311
+ dnsPropagationDelayMs: this.options.exactDnsPropagationDelayMs ?? 60000,
312
+ });
313
+ }
314
+ this.started = true;
247
315
 
248
316
  // Setup graceful shutdown handlers (store references for removal in stop())
249
317
  this.boundSigintHandler = () => this.handleSignal('SIGINT');
@@ -256,6 +324,8 @@ export class SmartAcme {
256
324
  * Stops the SmartAcme instance and closes certificate store connections.
257
325
  */
258
326
  public async stop() {
327
+ this.started = false;
328
+ this.lifecycleAbort.abort();
259
329
  // Remove signal handlers so the process can exit cleanly
260
330
  if (this.boundSigintHandler) {
261
331
  process.removeListener('SIGINT', this.boundSigintHandler);
@@ -265,8 +335,13 @@ export class SmartAcme {
265
335
  process.removeListener('SIGTERM', this.boundSigtermHandler);
266
336
  this.boundSigtermHandler = null;
267
337
  }
268
- // Stop the task manager
338
+ // Observe active requests before rejecting queued work. The task manager
339
+ // must close its queue before we drain callers waiting on that queue.
340
+ const pendingExactRequests = Promise.allSettled([...this.exactRequests.values()]);
269
341
  await this.taskManager.stop();
342
+ await pendingExactRequests;
343
+ this.exactRequests.clear();
344
+ this.exactIssuer = undefined;
270
345
  // Destroy ACME HTTP transport (closes keep-alive sockets)
271
346
  if (this.client) {
272
347
  this.client.destroy();
@@ -279,6 +354,64 @@ export class SmartAcme {
279
354
  await (this.certmanager as any).close();
280
355
  }
281
356
  }
357
+
358
+ /** Bind durable work to the current account and issuer without creating an order. */
359
+ public getCertificateIdentity(request: IExactCertificateRequest): IExactCertificateIdentity {
360
+ return this.requireExactIssuer().identity(request);
361
+ }
362
+
363
+ /** Ensure a certificate whose SANs exactly match the explicit identifiers. */
364
+ public async getCertificateForIdentifiers(request: IExactCertificateRequest): Promise<TExactCertificateResult> {
365
+ return await this.runExact(request);
366
+ }
367
+
368
+ public async getCertificateIssuanceStatus(request: IExactCertificateRequest): Promise<IExactIssuanceInfo | null> {
369
+ return await this.requireExactIssuer().status(request);
370
+ }
371
+
372
+ /** Administrative recovery; embedding services must authorize the caller. */
373
+ public async recoverCertificateIssuance(request: IExactIssuanceRecoveryRequest): Promise<TExactCertificateResult> {
374
+ return await this.runExact(request, request);
375
+ }
376
+
377
+ private requireExactIssuer(): ExactCertificateIssuer {
378
+ if (!this.started || !this.exactIssuer) throw new Error('SmartAcme explicit issuance is not running or configured');
379
+ return this.exactIssuer;
380
+ }
381
+
382
+ private async runExact(request: IExactCertificateRequest, recovery?: IExactIssuanceRecoveryRequest): Promise<TExactCertificateResult> {
383
+ const issuer = this.requireExactIssuer();
384
+ const identity = issuer.identity(request);
385
+ const recoverySnapshot = recovery ? {
386
+ namespace: identity.namespace, identifiers: [...identity.identifiers],
387
+ issuanceId: recovery.issuanceId, expectedRevision: recovery.expectedRevision, action: recovery.action,
388
+ } : undefined;
389
+ const existing = this.exactRequests.get(identity.certificateKey);
390
+ if (existing) {
391
+ if (recovery) throw new Error('Issuance is running; recover after its current operation completes');
392
+ return await existing;
393
+ }
394
+ if (this.exactRequests.size >= (this.options.maxPendingExactRequests ?? 64)) {
395
+ throw new Error('Explicit issuance admission limit reached');
396
+ }
397
+ const operation = (async (): Promise<TExactCertificateResult> => {
398
+ if (!recovery) {
399
+ const cached = await issuer.cached(identity);
400
+ if (cached) return cached;
401
+ }
402
+ this.lifecycleAbort.signal.throwIfAborted();
403
+ const result = await this.taskManager.triggerTaskConstrained(this.certIssuanceTask, {
404
+ certDomainName: identity.certificateKey, domainArg: identity.identifiers[0],
405
+ isWildcardRequest: false, includeWildcard: false, forceRenew: false,
406
+ exact: { request: { namespace: identity.namespace, identifiers: identity.identifiers }, recovery: recoverySnapshot },
407
+ } satisfies ICertIssuanceInput) as TExactCertificateResult | undefined;
408
+ if (!result) throw new Error('Explicit issuance completed without a result');
409
+ return result;
410
+ })();
411
+ this.exactRequests.set(identity.certificateKey, operation);
412
+ try { return await operation; }
413
+ finally { this.exactRequests.delete(identity.certificateKey); }
414
+ }
282
415
  /** Retry helper with exponential backoff and AcmeError awareness */
283
416
  private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
284
417
  let attempt = 0;
@@ -397,6 +530,7 @@ export class SmartAcme {
397
530
  domainArg,
398
531
  isWildcardRequest,
399
532
  includeWildcard: options?.includeWildcard ?? false,
533
+ forceRenew,
400
534
  };
401
535
 
402
536
  const result = await this.taskManager.triggerTaskConstrained(
package/readme.hints.md DELETED
@@ -1,81 +0,0 @@
1
- - this repo is dependent on letsencrypt and its limits
2
- - to simpify the outside API, smartacme is stateful, meaning it works with a mongodb and a collection called 'SmartacmeCert'.
3
-
4
- ## Certificate Request Behavior
5
-
6
- As of v7.4.0, SmartAcme no longer automatically requests wildcard certificates for all domain requests. This change was made to fix issues with HTTP-01 only configurations which cannot validate wildcard domains.
7
-
8
- - By default, `getCertificateForDomain('example.com')` only requests a certificate for `example.com`
9
- - To request both regular and wildcard certificates, use `getCertificateForDomain('example.com', { includeWildcard: true })`
10
- - Wildcard certificates require a DNS-01 challenge handler to be configured
11
- - Direct wildcard requests like `getCertificateForDomain('*.example.com')` only request the wildcard certificate
12
-
13
- This change ensures HTTP-01 only configurations work properly while still allowing wildcard certificates when needed and supported.
14
-
15
- ## ACME Protocol Implementation
16
-
17
- As of v8.1.0, the `acme-client` npm package has been replaced with a custom OOP implementation under `ts/acme/`. This uses `node:crypto` for all cryptographic operations and `@peculiar/x509` solely for CSR generation. The implementation follows RFC 8555.
18
-
19
- Key files:
20
- - `ts/acme/acme.classes.client.ts` — Top-level facade (`AcmeClient`), accepts optional `logger` callback
21
- - `ts/acme/acme.classes.crypto.ts` — Key gen, JWK, JWS signing, CSR (`AcmeCrypto`)
22
- - `ts/acme/acme.classes.http-client.ts` — JWS-signed HTTP transport with nonce management and logging
23
- - `ts/acme/acme.classes.error.ts` — Structured `AcmeError` with type URN, subproblems, Retry-After, `isRetryable`/`isRateLimited`
24
- - `ts/acme/acme.classes.account.ts` — Account registration
25
- - `ts/acme/acme.classes.order.ts` — Order lifecycle + polling
26
- - `ts/acme/acme.classes.challenge.ts` — Key authorization + challenge completion
27
- - `ts/acme/acme.classes.directory.ts` — CA directory URL constants (`ACME_DIRECTORY_URLS`)
28
-
29
- Usage in `ts/plugins.ts`: `import * as acme from './acme/index.js'` (replaces `acme-client`)
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
-
46
- ## ACME Directory Server (ts_server/)
47
-
48
- As of v9.2.0, a built-in ACME Directory Server lives under `ts_server/`. This is a full RFC 8555-compliant CA server that allows running your own Certificate Authority.
49
-
50
- Key files:
51
- - `ts_server/server.classes.acmeserver.ts` — Top-level `AcmeServer` facade (start/stop/config)
52
- - `ts_server/server.classes.ca.ts` — Self-signed root CA generation + certificate signing via `@peculiar/x509`
53
- - `ts_server/server.classes.jws.verifier.ts` — JWS signature verification (inverse of `AcmeCrypto.createJws`)
54
- - `ts_server/server.classes.router.ts` — Minimal HTTP router with `:param` support using raw `node:http`
55
- - `ts_server/server.classes.nonce.ts` — Single-use replay nonce management
56
- - `ts_server/server.classes.challenge.verifier.ts` — HTTP-01/DNS-01 verification (with bypass mode)
57
- - `ts_server/server.classes.account.store.ts` — In-memory account storage
58
- - `ts_server/server.classes.order.store.ts` — In-memory order/authz/challenge/cert storage
59
- - `ts_server/server.handlers.*.ts` — Route handlers for each ACME endpoint
60
-
61
- Design decisions:
62
- - Uses raw `node:http` (no framework dependency — `@api.global/typedserver` was explicitly removed in v8.1.0)
63
- - Zero new dependencies: uses `node:crypto`, `@peculiar/x509`, and existing project deps
64
- - Reuses `AcmeCrypto` for JWK thumbprint/base64url, ACME interfaces for response types, `AcmeError` patterns
65
- - `AcmeCrypto.getAlg()` was made public (was private) for use by the JWS verifier
66
- - Storage interfaces (`IServerAccountStore`, `IServerOrderStore`) are pluggable, with in-memory defaults
67
- - `challengeVerification: false` option auto-approves challenges for testing
68
- - `tsbuild tsfolders` automatically compiles `ts_server/` to `dist_ts_server/`
69
-
70
- ## Dependency Notes
71
-
72
- - `acme-client` was replaced with custom implementation in `ts/acme/` + `@peculiar/x509` for CSR generation
73
- - `@push.rocks/smartfile`, `@api.global/typedserver`, `@push.rocks/smartrequest`, `@push.rocks/smartpromise` were removed as unused dependencies in v8.1.0
74
- - The `@apiclient.xyz/cloudflare` `convenience` namespace is deprecated but still functional. The `Dns01Handler` accepts an `IConvenientDnsProvider` interface which remains stable.
75
- - Test imports use `@git.zone/tstest/tapbundle` (not `@push.rocks/tapbundle`)
76
- - Build uses `tsbuild tsfolders` (v4.4.0+) — auto-discovers and compiles `ts/` and `ts_server/` directories
77
- - `@peculiar/x509` v2.0.0 removed `reflect-metadata` from its dependencies. Since `tsyringe` (used internally by `@peculiar/x509`) requires the Reflect polyfill, `reflect-metadata` is now a direct dependency and imported in `ts/acme/acme.classes.crypto.ts` and `ts_server/server.classes.ca.ts`.
78
- - `@push.rocks/taskbuffer` upgraded from v6 to v8 (required by smartdata 7.1.3). API surface is backward-compatible.
79
- - TypeScript 6 (via tsbuild 4.4.0) requires `"types": ["node"]` in tsconfig.json for `ts_server/` compilation to resolve `@types/node`.
80
- - TypeScript 6 deprecated `baseUrl` in tsconfig — removed it since `paths` was empty.
81
- - Config file renamed from `npmextra.json` to `.smartconfig.json` (ecosystem convention change).
package/readme.plan.md DELETED
@@ -1,3 +0,0 @@
1
- ## Plan
2
-
3
- Move the