diff --git a/changelog.md b/changelog.md
index 8a890c9..584835c 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,12 @@
# Changelog
+## 2026-04-05 - 13.0.6 - fix(certificates)
+resolve base-domain certificate lookups and route profile list inputs
+
+- Look up ACME certificate metadata by base domain first, with fallback to the exact domain, so subdomain certificate status and deletion work reliably.
+- Trigger certificate reprovisioning through SmartProxy routes and clear cached status before refresh, including force-renew cache invalidation handling.
+- Replace comma-separated target profile form fields with list inputs and route suggestions for domains, targets, and route references.
+
## 2026-04-05 - 13.0.5 - fix(ts_web)
replace custom section heading component with dees-heading across ops views
diff --git a/package.json b/package.json
index 8d39d12..bc24d1f 100644
--- a/package.json
+++ b/package.json
@@ -35,12 +35,12 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
- "@design.estate/dees-catalog": "^3.61.0",
+ "@design.estate/dees-catalog": "^3.61.1",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
- "@push.rocks/smartacme": "^9.4.0",
+ "@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.6",
"@push.rocks/smartdb": "^2.5.9",
"@push.rocks/smartdns": "^7.9.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2306319..3829e34 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0
version: 7.1.0
'@design.estate/dees-catalog':
- specifier: ^3.61.0
- version: 3.61.0(@tiptap/pm@2.27.2)
+ specifier: ^3.61.1
+ version: 3.61.1(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -39,8 +39,8 @@ importers:
specifier: ^6.1.3
version: 6.1.3
'@push.rocks/smartacme':
- specifier: ^9.4.0
- version: 9.4.0(socks@2.8.7)
+ specifier: ^9.5.0
+ version: 9.5.0(socks@2.8.7)
'@push.rocks/smartdata':
specifier: ^7.1.6
version: 7.1.6(socks@2.8.7)
@@ -350,8 +350,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
- '@design.estate/dees-catalog@3.61.0':
- resolution: {integrity: sha512-gBcNotstwnapGuf/DSapVu+R8F1ITp1wypDOw4NLFak0FwOmPb7ao5pALUbcz+MZmZmB0VuBuqN5GcTyIGIX3Q==}
+ '@design.estate/dees-catalog@3.61.1':
+ resolution: {integrity: sha512-RA85O87pRM3QPlncBNB27wJTl+UVGaGtx8l5DaeOhru78agu4+y+ByAdUgS9Ahdpr/ZZVYSAADkZETsf/l08UQ==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -1108,8 +1108,8 @@ packages:
'@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
- '@push.rocks/smartacme@9.4.0':
- resolution: {integrity: sha512-mSqsI859mHI9fCZxLfayzPf/WvukDFzVHOh02vXq3ujxbb5M+ArMnXe0MmC2egR9GeXmQTm3DTENaETX5ffMtw==}
+ '@push.rocks/smartacme@9.5.0':
+ resolution: {integrity: sha512-soOjER2c4umKaOSsB6uq/k08aA9rfd7Dicm6DNX3XB16LjCjldVHpizeOGqRBkFga+VroDQ/rEYecHT5tFiWvg==}
'@push.rocks/smartarchive@4.2.4':
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
@@ -4358,7 +4358,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260317.1
- '@design.estate/dees-catalog': 3.61.0(@tiptap/pm@2.27.2)
+ '@design.estate/dees-catalog': 3.61.1(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
@@ -4887,7 +4887,7 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
- '@design.estate/dees-catalog@3.61.0(@tiptap/pm@2.27.2)':
+ '@design.estate/dees-catalog@3.61.1(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
@@ -5977,7 +5977,7 @@ snapshots:
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
- '@push.rocks/smartacme@9.4.0(socks@2.8.7)':
+ '@push.rocks/smartacme@9.5.0(socks@2.8.7)':
dependencies:
'@apiclient.xyz/cloudflare': 7.1.0
'@peculiar/x509': 2.0.0
@@ -5997,11 +5997,14 @@ snapshots:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
- '@nuxt/kit'
+ - bare-abort-controller
+ - bare-buffer
- encoding
- gcp-metadata
- kerberos
- mongodb-client-encryption
- react
+ - react-native-b4a
- snappy
- socks
- supports-color
@@ -6965,7 +6968,7 @@ snapshots:
'@serve.zone/catalog@2.11.2(@tiptap/pm@2.27.2)':
dependencies:
- '@design.estate/dees-catalog': 3.61.0(@tiptap/pm@2.27.2)
+ '@design.estate/dees-catalog': 3.61.1(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts
index a3ffb42..dde9958 100644
--- a/ts/00_commitinfo_data.ts
+++ b/ts/00_commitinfo_data.ts
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
- version: '13.0.5',
+ version: '13.0.6',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts
index 266dbf1..30f9123 100644
--- a/ts/classes.dcrouter.ts
+++ b/ts/classes.dcrouter.ts
@@ -1076,7 +1076,10 @@ export class DcRouter {
if (!expiryDate) {
try {
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
- const certDoc = await AcmeCertDoc.findByDomain(cleanDomain);
+ const domParts = cleanDomain.split('.');
+ const baseDomain = domParts.length > 2 ? domParts.slice(-2).join('.') : cleanDomain;
+ const certDoc = await AcmeCertDoc.findByDomain(baseDomain)
+ || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (certDoc?.validUntil) {
expiryDate = new Date(certDoc.validUntil).toISOString();
}
diff --git a/ts/opsserver/handlers/certificate.handler.ts b/ts/opsserver/handlers/certificate.handler.ts
index eb85464..1281079 100644
--- a/ts/opsserver/handlers/certificate.handler.ts
+++ b/ts/opsserver/handlers/certificate.handler.ts
@@ -191,7 +191,11 @@ export class CertificateHandler {
// Check persisted cert data from smartdata document classes
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
- const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
+ // SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
+ const parts = cleanDomain.split('.');
+ const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
+ const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
+ || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
if (acmeDoc?.validUntil) {
@@ -331,31 +335,32 @@ export class CertificateHandler {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
- // Clear status map entry so it gets refreshed
+ // Find routes matching this domain — needed to provision through SmartProxy
+ const routeNames = dcRouter.findRouteNamesForDomain(domain);
+ if (routeNames.length === 0) {
+ return { success: false, message: `No routes found for domain '${domain}'` };
+ }
+
+ // If forceRenew, invalidate SmartAcme's cache so the next provision gets a fresh cert
+ if (forceRenew && dcRouter.smartAcme) {
+ try {
+ await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true });
+ } catch {
+ // Cache invalidation failed — proceed with provisioning anyway
+ }
+ }
+
+ // Clear status map entry so it gets refreshed by the certificate-issued event
dcRouter.certificateStatusMap.delete(domain);
- // Try to provision via SmartAcme directly
- if (dcRouter.smartAcme) {
- try {
- await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: forceRenew ?? false });
- return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
- } catch (err: unknown) {
- return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
- }
+ // Provision through SmartProxy — this triggers the full pipeline:
+ // certProvisionFunction → bridge.loadCertificate → certificate-issued event → status map updated
+ try {
+ await smartProxy.provisionCertificate(routeNames[0]);
+ return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
+ } catch (err: unknown) {
+ return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
-
- // Fallback: try provisioning via the first matching route
- const routeNames = dcRouter.findRouteNamesForDomain(domain);
- if (routeNames.length > 0) {
- try {
- await smartProxy.provisionCertificate(routeNames[0]);
- return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
- } catch (err: unknown) {
- return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
- }
- }
-
- return { success: false, message: `No routes found for domain '${domain}'` };
}
/**
@@ -364,9 +369,12 @@ export class CertificateHandler {
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
+ const parts = cleanDomain.split('.');
+ const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
- // Delete from smartdata document classes
- const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
+ // Delete from smartdata document classes (try base domain first, then exact)
+ const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
+ || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (acmeDoc) {
await acmeDoc.delete();
}
diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts
index a3ffb42..dde9958 100644
--- a/ts_web/00_commitinfo_data.ts
+++ b/ts_web/00_commitinfo_data.ts
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
- version: '13.0.5',
+ version: '13.0.6',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
diff --git a/ts_web/elements/ops-view-targetprofiles.ts b/ts_web/elements/ops-view-targetprofiles.ts
index 2af1f2f..4a8585c 100644
--- a/ts_web/elements/ops-view-targetprofiles.ts
+++ b/ts_web/elements/ops-view-targetprofiles.ts
@@ -148,17 +148,27 @@ export class OpsViewTargetProfiles extends DeesElement {
`;
}
+ private getRouteCandidates() {
+ const routeState = appstate.routeManagementStatePart.getState();
+ const routes = routeState?.mergedRoutes || [];
+ return routes
+ .filter((mr) => mr.route.name)
+ .map((mr) => ({ viewKey: mr.route.name! }));
+ }
+
private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
+ const routeCandidates = this.getRouteCandidates();
+
DeesModal.createAndShow({
heading: 'Create Target Profile',
content: html`
-
-
-
+
+
+
`,
menuOptions: [
@@ -172,30 +182,26 @@ export class OpsViewTargetProfiles extends DeesElement {
const data = await form.collectFormData();
if (!data.name) return;
- const domains = data.domains
- ? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
- : undefined;
- const targets = data.targets
- ? String(data.targets).split(',').map((s: string) => {
- const trimmed = s.trim();
- const lastColon = trimmed.lastIndexOf(':');
- if (lastColon === -1) return null;
- return {
- host: trimmed.substring(0, lastColon),
- port: parseInt(trimmed.substring(lastColon + 1), 10),
- };
- }).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
- : undefined;
- const routeRefs = data.routeRefs
- ? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
- : undefined;
+ const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
+ const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
+ const targets = targetStrings
+ .map((s: string) => {
+ const lastColon = s.lastIndexOf(':');
+ if (lastColon === -1) return null;
+ return {
+ host: s.substring(0, lastColon),
+ port: parseInt(s.substring(lastColon + 1), 10),
+ };
+ })
+ .filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
+ const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
- domains,
- targets,
- routeRefs,
+ domains: domains.length > 0 ? domains : undefined,
+ targets: targets.length > 0 ? targets : undefined,
+ routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
});
modalArg.destroy();
},
@@ -205,20 +211,22 @@ export class OpsViewTargetProfiles extends DeesElement {
}
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
- const currentDomains = profile.domains?.join(', ') ?? '';
- const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? '';
- const currentRouteRefs = profile.routeRefs?.join(', ') ?? '';
+ const currentDomains = profile.domains || [];
+ const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`) || [];
+ const currentRouteRefs = profile.routeRefs || [];
const { DeesModal } = await import('@design.estate/dees-catalog');
+ const routeCandidates = this.getRouteCandidates();
+
DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`,
content: html`
-
-
-
+
+
+
`,
menuOptions: [
@@ -231,24 +239,19 @@ export class OpsViewTargetProfiles extends DeesElement {
if (!form) return;
const data = await form.collectFormData();
- const domains = data.domains
- ? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
- : [];
- const targets = data.targets
- ? String(data.targets).split(',').map((s: string) => {
- const trimmed = s.trim();
- if (!trimmed) return null;
- const lastColon = trimmed.lastIndexOf(':');
- if (lastColon === -1) return null;
- return {
- host: trimmed.substring(0, lastColon),
- port: parseInt(trimmed.substring(lastColon + 1), 10),
- };
- }).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
- : [];
- const routeRefs = data.routeRefs
- ? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
- : [];
+ const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
+ const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
+ const targets = targetStrings
+ .map((s: string) => {
+ const lastColon = s.lastIndexOf(':');
+ if (lastColon === -1) return null;
+ return {
+ host: s.substring(0, lastColon),
+ port: parseInt(s.substring(lastColon + 1), 10),
+ };
+ })
+ .filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
+ const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id,