fix(certificates): resolve base-domain certificate lookups and route profile list inputs

This commit is contained in:
2026-04-05 11:29:47 +00:00
parent 4e9b09616d
commit b2ccd54079
8 changed files with 113 additions and 89 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # 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) ## 2026-04-05 - 13.0.5 - fix(ts_web)
replace custom section heading component with dees-heading across ops views replace custom section heading component with dees-heading across ops views

View File

@@ -35,12 +35,12 @@
"@api.global/typedserver": "^8.4.6", "@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0", "@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", "@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0", "@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0", "@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3", "@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/smartdata": "^7.1.6",
"@push.rocks/smartdb": "^2.5.9", "@push.rocks/smartdb": "^2.5.9",
"@push.rocks/smartdns": "^7.9.0", "@push.rocks/smartdns": "^7.9.0",

27
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
'@design.estate/dees-catalog': '@design.estate/dees-catalog':
specifier: ^3.61.0 specifier: ^3.61.1
version: 3.61.0(@tiptap/pm@2.27.2) version: 3.61.1(@tiptap/pm@2.27.2)
'@design.estate/dees-element': '@design.estate/dees-element':
specifier: ^2.2.4 specifier: ^2.2.4
version: 2.2.4 version: 2.2.4
@@ -39,8 +39,8 @@ importers:
specifier: ^6.1.3 specifier: ^6.1.3
version: 6.1.3 version: 6.1.3
'@push.rocks/smartacme': '@push.rocks/smartacme':
specifier: ^9.4.0 specifier: ^9.5.0
version: 9.4.0(socks@2.8.7) version: 9.5.0(socks@2.8.7)
'@push.rocks/smartdata': '@push.rocks/smartdata':
specifier: ^7.1.6 specifier: ^7.1.6
version: 7.1.6(socks@2.8.7) version: 7.1.6(socks@2.8.7)
@@ -350,8 +350,8 @@ packages:
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.61.0': '@design.estate/dees-catalog@3.61.1':
resolution: {integrity: sha512-gBcNotstwnapGuf/DSapVu+R8F1ITp1wypDOw4NLFak0FwOmPb7ao5pALUbcz+MZmZmB0VuBuqN5GcTyIGIX3Q==} resolution: {integrity: sha512-RA85O87pRM3QPlncBNB27wJTl+UVGaGtx8l5DaeOhru78agu4+y+ByAdUgS9Ahdpr/ZZVYSAADkZETsf/l08UQ==}
'@design.estate/dees-comms@1.0.30': '@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -1108,8 +1108,8 @@ packages:
'@push.rocks/qenv@6.1.3': '@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==} resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
'@push.rocks/smartacme@9.4.0': '@push.rocks/smartacme@9.5.0':
resolution: {integrity: sha512-mSqsI859mHI9fCZxLfayzPf/WvukDFzVHOh02vXq3ujxbb5M+ArMnXe0MmC2egR9GeXmQTm3DTENaETX5ffMtw==} resolution: {integrity: sha512-soOjER2c4umKaOSsB6uq/k08aA9rfd7Dicm6DNX3XB16LjCjldVHpizeOGqRBkFga+VroDQ/rEYecHT5tFiWvg==}
'@push.rocks/smartarchive@4.2.4': '@push.rocks/smartarchive@4.2.4':
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==} resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
@@ -4358,7 +4358,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3) '@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260317.1 '@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 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0 '@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -4887,7 +4887,7 @@ snapshots:
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@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: dependencies:
'@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4 '@design.estate/dees-element': 2.2.4
@@ -5977,7 +5977,7 @@ snapshots:
'@push.rocks/smartlog': 3.2.1 '@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0 '@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: dependencies:
'@apiclient.xyz/cloudflare': 7.1.0 '@apiclient.xyz/cloudflare': 7.1.0
'@peculiar/x509': 2.0.0 '@peculiar/x509': 2.0.0
@@ -5997,11 +5997,14 @@ snapshots:
- '@aws-sdk/credential-providers' - '@aws-sdk/credential-providers'
- '@mongodb-js/zstd' - '@mongodb-js/zstd'
- '@nuxt/kit' - '@nuxt/kit'
- bare-abort-controller
- bare-buffer
- encoding - encoding
- gcp-metadata - gcp-metadata
- kerberos - kerberos
- mongodb-client-encryption - mongodb-client-encryption
- react - react
- react-native-b4a
- snappy - snappy
- socks - socks
- supports-color - supports-color
@@ -6965,7 +6968,7 @@ snapshots:
'@serve.zone/catalog@2.11.2(@tiptap/pm@2.27.2)': '@serve.zone/catalog@2.11.2(@tiptap/pm@2.27.2)':
dependencies: 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-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4 '@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0 '@design.estate/dees-wcctools': 3.8.0

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.0.5', version: '13.0.6',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -1076,7 +1076,10 @@ export class DcRouter {
if (!expiryDate) { if (!expiryDate) {
try { try {
const cleanDomain = entry.domain.replace(/^\*\.?/, ''); 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) { if (certDoc?.validUntil) {
expiryDate = new Date(certDoc.validUntil).toISOString(); expiryDate = new Date(certDoc.validUntil).toISOString();
} }

View File

@@ -191,7 +191,11 @@ export class CertificateHandler {
// Check persisted cert data from smartdata document classes // Check persisted cert data from smartdata document classes
if (status === 'unknown') { if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, ''); 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; const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
if (acmeDoc?.validUntil) { if (acmeDoc?.validUntil) {
@@ -331,42 +335,46 @@ export class CertificateHandler {
await dcRouter.certProvisionScheduler.clearBackoff(domain); 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); dcRouter.certificateStatusMap.delete(domain);
// Try to provision via SmartAcme directly // Provision through SmartProxy — this triggers the full pipeline:
if (dcRouter.smartAcme) { // certProvisionFunction → bridge.loadCertificate → certificate-issued event → status map updated
try { try {
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: forceRenew ?? false }); await smartProxy.provisionCertificate(routeNames[0]);
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` }; return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err: unknown) { } catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` }; 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}'` };
}
/** /**
* Delete certificate data for a domain from storage * Delete certificate data for a domain from storage
*/ */
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> { private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, ''); const cleanDomain = domain.replace(/^\*\.?/, '');
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
// Delete from smartdata document classes // Delete from smartdata document classes (try base domain first, then exact)
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain); const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (acmeDoc) { if (acmeDoc) {
await acmeDoc.delete(); await acmeDoc.delete();
} }

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.0.5', version: '13.0.6',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -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() { private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const routeCandidates = this.getRouteCandidates();
DeesModal.createAndShow({ DeesModal.createAndShow({
heading: 'Create Target Profile', heading: 'Create Target Profile',
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text> <dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text> <dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} ></dees-input-text> <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'}></dees-input-text> <dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'}></dees-input-text> <dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
</dees-form> </dees-form>
`, `,
menuOptions: [ menuOptions: [
@@ -172,30 +182,26 @@ export class OpsViewTargetProfiles extends DeesElement {
const data = await form.collectFormData(); const data = await form.collectFormData();
if (!data.name) return; if (!data.name) return;
const domains = data.domains const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean) const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
: undefined; const targets = targetStrings
const targets = data.targets .map((s: string) => {
? String(data.targets).split(',').map((s: string) => { const lastColon = s.lastIndexOf(':');
const trimmed = s.trim();
const lastColon = trimmed.lastIndexOf(':');
if (lastColon === -1) return null; if (lastColon === -1) return null;
return { return {
host: trimmed.substring(0, lastColon), host: s.substring(0, lastColon),
port: parseInt(trimmed.substring(lastColon + 1), 10), port: parseInt(s.substring(lastColon + 1), 10),
}; };
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port)) })
: undefined; .filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs = data.routeRefs const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, { await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name), name: String(data.name),
description: data.description ? String(data.description) : undefined, description: data.description ? String(data.description) : undefined,
domains, domains: domains.length > 0 ? domains : undefined,
targets, targets: targets.length > 0 ? targets : undefined,
routeRefs, routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
}); });
modalArg.destroy(); modalArg.destroy();
}, },
@@ -205,20 +211,22 @@ export class OpsViewTargetProfiles extends DeesElement {
} }
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) { private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains?.join(', ') ?? ''; const currentDomains = profile.domains || [];
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? ''; const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`) || [];
const currentRouteRefs = profile.routeRefs?.join(', ') ?? ''; const currentRouteRefs = profile.routeRefs || [];
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const routeCandidates = this.getRouteCandidates();
DeesModal.createAndShow({ DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`, heading: `Edit Profile: ${profile.name}`,
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text> <dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text> <dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} .value=${currentDomains}></dees-input-text> <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'} .value=${currentTargets}></dees-input-text> <dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'} .value=${currentRouteRefs}></dees-input-text> <dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
</dees-form> </dees-form>
`, `,
menuOptions: [ menuOptions: [
@@ -231,24 +239,19 @@ export class OpsViewTargetProfiles extends DeesElement {
if (!form) return; if (!form) return;
const data = await form.collectFormData(); const data = await form.collectFormData();
const domains = data.domains const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean) const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
: []; const targets = targetStrings
const targets = data.targets .map((s: string) => {
? String(data.targets).split(',').map((s: string) => { const lastColon = s.lastIndexOf(':');
const trimmed = s.trim();
if (!trimmed) return null;
const lastColon = trimmed.lastIndexOf(':');
if (lastColon === -1) return null; if (lastColon === -1) return null;
return { return {
host: trimmed.substring(0, lastColon), host: s.substring(0, lastColon),
port: parseInt(trimmed.substring(lastColon + 1), 10), port: parseInt(s.substring(lastColon + 1), 10),
}; };
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port)) })
: []; .filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs = data.routeRefs const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, { await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id, id: profile.id,