From 4aba8cc353a29f936a2224b144b08cd4190d0373 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 13 Apr 2026 17:38:23 +0000 Subject: [PATCH] feat(routes): unify route storage and management across config, email, dns, and API origins --- changelog.md | 7 + test/test.apiclient.ts | 50 +--- ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 100 +++---- ts/config/classes.reference-resolver.ts | 26 +- ts/config/classes.route-config-manager.ts | 276 ++++++++---------- ts/config/classes.target-profile-manager.ts | 27 +- ts/db/documents/classes.route-override.doc.ts | 32 -- ...ored-route.doc.ts => classes.route.doc.ts} | 21 +- ts/db/documents/index.ts | 3 +- .../handlers/network-target.handler.ts | 4 +- .../handlers/route-management.handler.ts | 36 +-- .../handlers/source-profile.handler.ts | 4 +- ts_apiclient/classes.route.ts | 61 +--- ts_interfaces/data/route-management.ts | 26 +- ts_interfaces/requests/route-management.ts | 51 +--- ts_migrations/index.ts | 28 ++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 52 ---- ts_web/elements/network/ops-view-routes.ts | 188 +++++------- 20 files changed, 349 insertions(+), 647 deletions(-) delete mode 100644 ts/db/documents/classes.route-override.doc.ts rename ts/db/documents/{classes.stored-route.doc.ts => classes.route.doc.ts} (55%) diff --git a/changelog.md b/changelog.md index e72ba64..ef0070e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-13 - 13.16.0 - feat(routes) +unify route storage and management across config, email, dns, and API origins + +- Persist config-, email-, and dns-seeded routes in the database alongside API-created routes using a single RouteDoc model with origin tracking +- Remove hardcoded-route override handling in favor of direct route CRUD and toggle operations by route id across the API client, handlers, and web UI +- Add a migration that renames stored route storage, sets migrated routes to origin="api", and drops obsolete route override data + ## 2026-04-13 - 13.15.1 - fix(monitoring) improve domain activity aggregation for multi-domain and wildcard routes diff --git a/test/test.apiclient.ts b/test/test.apiclient.ts index 40c2a5d..2df7ed3 100644 --- a/test/test.apiclient.ts +++ b/test/test.apiclient.ts @@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => { match: { ports: 443, domains: 'example.com' }, action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] }, }, - source: 'programmatic', + id: 'route-123', enabled: true, - overridden: false, - storedRouteId: 'route-123', + origin: 'api', createdAt: 1000, updatedAt: 2000, }); expect(route.name).toEqual('test-route'); - expect(route.source).toEqual('programmatic'); + expect(route.id).toEqual('route-123'); expect(route.enabled).toEqual(true); - expect(route.overridden).toEqual(false); - expect(route.storedRouteId).toEqual('route-123'); + expect(route.origin).toEqual('api'); expect(route.routeConfig.match.ports).toEqual(443); }); -tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => { - const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' }); - const route = new Route(client, { - route: { - name: 'hardcoded-route', - match: { ports: 80 }, - action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] }, - }, - source: 'hardcoded', - enabled: true, - overridden: false, - // No storedRouteId for hardcoded routes - }); - - let updateError: Error | undefined; - try { - await route.update({ name: 'new-name' }); - } catch (e) { - updateError = e as Error; - } - expect(updateError).toBeTruthy(); - expect(updateError!.message).toInclude('hardcoded'); - - let deleteError: Error | undefined; - try { - await route.delete(); - } catch (e) { - deleteError = e as Error; - } - expect(deleteError).toBeTruthy(); - - let toggleError: Error | undefined; - try { - await route.toggle(false); - } catch (e) { - toggleError = e as Error; - } - expect(toggleError).toBeTruthy(); -}); - // ============================================================================= // Certificate resource class // ============================================================================= diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 8b12c34..27ca71a 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.15.1', + version: '13.16.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index e34a43c..b14ab72 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -312,8 +312,10 @@ export class DcRouter { // TypedRouter for API endpoints public typedrouter = new plugins.typedrequest.TypedRouter(); - // Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager) - private constructorRoutes: plugins.smartproxy.IRouteConfig[] = []; + // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding + private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = []; + private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = []; + private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = []; // Environment access private qenv = new plugins.qenv.Qenv('./', '.nogit/'); @@ -549,7 +551,6 @@ export class DcRouter { await this.targetProfileManager.initialize(); this.routeConfigManager = new RouteConfigManager( - () => this.getConstructorRoutes(), () => this.smartProxy, () => this.options.http3, this.options.vpnConfig?.enabled @@ -564,7 +565,7 @@ export class DcRouter { } : undefined, this.referenceResolver, - // Sync merged routes to RemoteIngressManager whenever routes change, + // Sync routes to RemoteIngressManager whenever routes change, // then push updated derived ports to the Rust hub binary (routes) => { if (this.remoteIngressManager) { @@ -577,7 +578,11 @@ export class DcRouter { ); this.apiTokenManager = new ApiTokenManager(); await this.apiTokenManager.initialize(); - await this.routeConfigManager.initialize(); + await this.routeConfigManager.initialize( + this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], + this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], + this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], + ); // Seed default profiles/targets if DB is empty and seeding is enabled const seeder = new DbSeeder(this.referenceResolver); @@ -881,31 +886,30 @@ export class DcRouter { this.smartProxy = undefined; } - let routes: plugins.smartproxy.IRouteConfig[] = []; + // Assemble seed routes from constructor config — these will be seeded into DB + // by RouteConfigManager.initialize() when the ConfigManagers service starts. + this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[]; + logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`); - // If user provides full SmartProxy config, use its routes. - // NOTE: `smartProxyConfig.acme` is now seed-only — consumed by - // AcmeConfigManager on first boot. The live ACME config always comes - // from the DB via `this.acmeConfigManager.getConfig()`. - if (this.options.smartProxyConfig) { - routes = this.options.smartProxyConfig.routes || []; - logger.log('info', `Found ${routes.length} routes in config`); - } - - // If email config exists, automatically add email routes + this.seedEmailRoutes = []; if (this.options.emailConfig) { - const emailRoutes = this.generateEmailRoutes(this.options.emailConfig); - logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) }); - routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy + this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig); + logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) }); } - // If DNS is configured, add DNS routes + this.seedDnsRoutes = []; if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { - const dnsRoutes = this.generateDnsRoutes(); - logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) }); - routes = [...routes, ...dnsRoutes]; + this.seedDnsRoutes = this.generateDnsRoutes(); + logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) }); } + // Combined routes for SmartProxy bootstrap (before DB routes are loaded) + let routes: plugins.smartproxy.IRouteConfig[] = [ + ...this.seedConfigRoutes, + ...this.seedEmailRoutes, + ...this.seedDnsRoutes, + ]; + // Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager. // If no config exists or it's disabled, SmartProxy's own ACME is turned off // and dcrouter's SmartAcme / certProvisionFunction are not wired. @@ -952,10 +956,6 @@ export class DcRouter { logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); } - // Cache constructor routes for RouteConfigManager (without VPN security baked in — - // applyRoutes() injects VPN security dynamically so it stays current with client changes) - this.constructorRoutes = [...routes]; - // If we have routes or need a basic SmartProxy instance, create it if (routes.length > 0 || this.options.smartProxyConfig) { logger.log('info', 'Setting up SmartProxy with combined configuration'); @@ -1406,14 +1406,6 @@ export class DcRouter { return names; } - /** - * Get the routes derived from constructor config (smartProxy + email + DNS). - * Used by RouteConfigManager as the "hardcoded" base. - */ - public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] { - return this.constructorRoutes; - } - public async stop() { logger.log('info', 'Stopping DcRouter services...'); @@ -1457,17 +1449,16 @@ export class DcRouter { // Update configuration this.options.smartProxyConfig = config; - // Update routes on RemoteIngressManager so derived ports stay in sync - if (this.remoteIngressManager && config.routes) { - this.remoteIngressManager.setRoutes(config.routes as any[]); - } - - // Start new SmartProxy with updated configuration (will include email routes if configured) + // Start new SmartProxy with updated configuration (rebuilds seed routes) await this.setupSmartProxy(); - // Re-apply programmatic routes and overrides after SmartProxy restart + // Re-seed and re-apply all routes after SmartProxy restart if (this.routeConfigManager) { - await this.routeConfigManager.initialize(); + await this.routeConfigManager.initialize( + this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], + this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], + this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], + ); } logger.log('info', 'SmartProxy configuration updated'); @@ -2185,13 +2176,14 @@ export class DcRouter { this.remoteIngressManager = new RemoteIngressManager(); await this.remoteIngressManager.initialize(); - // Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes - const currentRoutes = this.constructorRoutes; - this.remoteIngressManager.setRoutes(currentRoutes as any[]); + // Pass current bootstrap routes so the manager can derive edge ports initially. + // Once RouteConfigManager applies the full DB set, the onRoutesApplied callback + // will push the complete merged routes here. + const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.seedDnsRoutes]; + this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]); - // Race-condition fix: if ConfigManagers finished before us, re-apply routes - // so the callback delivers the full merged set (including DB-stored routes) - // to our newly-created remoteIngressManager. + // If ConfigManagers finished before us, re-apply routes + // so the callback delivers the full DB set to our newly-created remoteIngressManager. if (this.routeConfigManager) { await this.routeConfigManager.applyRoutes(); } @@ -2278,11 +2270,10 @@ export class DcRouter { if (!this.targetProfileManager) return [...ips]; - const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[]; - const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map(); + const allRoutes = this.routeConfigManager?.getRoutes() || new Map(); const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec( - targetProfileIds, routes, storedRoutes, + targetProfileIds, allRoutes, ); // Add target IPs directly @@ -2305,9 +2296,8 @@ export class DcRouter { await this.vpnManager.start(); - // Re-apply routes now that VPN clients are loaded — ensures hardcoded routes - // get correct profile-based ipAllowLists (not possible during setupSmartProxy since - // VPN server wasn't ready yet) + // Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes + // get correct profile-based ipAllowLists await this.routeConfigManager?.applyRoutes(); } diff --git a/ts/config/classes.reference-resolver.ts b/ts/config/classes.reference-resolver.ts index 941c904..f59f651 100644 --- a/ts/config/classes.reference-resolver.ts +++ b/ts/config/classes.reference-resolver.ts @@ -1,11 +1,11 @@ import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; -import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js'; +import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js'; import type { ISourceProfile, INetworkTarget, IRouteMetadata, - IStoredRoute, + IRoute, IRouteSecurity, } from '../../ts_interfaces/data/route-management.js'; @@ -81,7 +81,7 @@ export class ReferenceResolver { public async deleteProfile( id: string, force: boolean, - storedRoutes?: Map, + storedRoutes?: Map, ): Promise<{ success: boolean; message?: string }> { const profile = this.profiles.get(id); if (!profile) { @@ -131,7 +131,7 @@ export class ReferenceResolver { return [...this.profiles.values()]; } - public getProfileUsage(storedRoutes: Map): Map> { + public getProfileUsage(storedRoutes: Map): Map> { const usage = new Map>(); for (const profile of this.profiles.values()) { usage.set(profile.id, []); @@ -147,7 +147,7 @@ export class ReferenceResolver { public getProfileUsageForId( profileId: string, - storedRoutes: Map, + storedRoutes: Map, ): Array<{ id: string; routeName: string }> { const routes: Array<{ id: string; routeName: string }> = []; for (const [routeId, stored] of storedRoutes) { @@ -214,7 +214,7 @@ export class ReferenceResolver { public async deleteTarget( id: string, force: boolean, - storedRoutes?: Map, + storedRoutes?: Map, ): Promise<{ success: boolean; message?: string }> { const target = this.targets.get(id); if (!target) { @@ -263,7 +263,7 @@ export class ReferenceResolver { public getTargetUsageForId( targetId: string, - storedRoutes: Map, + storedRoutes: Map, ): Array<{ id: string; routeName: string }> { const routes: Array<{ id: string; routeName: string }> = []; for (const [routeId, stored] of storedRoutes) { @@ -334,20 +334,20 @@ export class ReferenceResolver { // ========================================================================= public async findRoutesByProfileRef(profileId: string): Promise { - const docs = await StoredRouteDoc.findAll(); + const docs = await RouteDoc.findAll(); return docs .filter((doc) => doc.metadata?.sourceProfileRef === profileId) .map((doc) => doc.id); } public async findRoutesByTargetRef(targetId: string): Promise { - const docs = await StoredRouteDoc.findAll(); + const docs = await RouteDoc.findAll(); return docs .filter((doc) => doc.metadata?.networkTargetRef === targetId) .map((doc) => doc.id); } - public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map): string[] { + public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map): string[] { const ids: string[] = []; for (const [routeId, stored] of storedRoutes) { if (stored.metadata?.sourceProfileRef === profileId) { @@ -357,7 +357,7 @@ export class ReferenceResolver { return ids; } - public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map): string[] { + public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map): string[] { const ids: string[] = []; for (const [routeId, stored] of storedRoutes) { if (stored.metadata?.networkTargetRef === targetId) { @@ -547,7 +547,7 @@ export class ReferenceResolver { private async clearProfileRefsOnRoutes(routeIds: string[]): Promise { for (const routeId of routeIds) { - const doc = await StoredRouteDoc.findById(routeId); + const doc = await RouteDoc.findById(routeId); if (doc?.metadata) { doc.metadata = { ...doc.metadata, @@ -562,7 +562,7 @@ export class ReferenceResolver { private async clearTargetRefsOnRoutes(routeIds: string[]): Promise { for (const routeId of routeIds) { - const doc = await StoredRouteDoc.findById(routeId); + const doc = await RouteDoc.findById(routeId); if (doc?.metadata) { doc.metadata = { ...doc.metadata, diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 7bfa57e..154375b 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -1,9 +1,8 @@ import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; -import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js'; +import { RouteDoc } from '../db/index.js'; import type { - IStoredRoute, - IRouteOverride, + IRoute, IMergedRoute, IRouteWarning, IRouteMetadata, @@ -46,13 +45,11 @@ class RouteUpdateMutex { } export class RouteConfigManager { - private storedRoutes = new Map(); - private overrides = new Map(); + private routes = new Map(); private warnings: IRouteWarning[] = []; private routeUpdateMutex = new RouteUpdateMutex(); constructor( - private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getHttp3Config?: () => IHttp3Config | undefined, private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[], @@ -60,52 +57,44 @@ export class RouteConfigManager { private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, ) {} - /** Expose stored routes map for reference resolution lookups. */ - public getStoredRoutes(): Map { - return this.storedRoutes; + /** Expose routes map for reference resolution lookups. */ + public getRoutes(): Map { + return this.routes; } /** - * Load persisted routes and overrides, compute warnings, apply to SmartProxy. + * Load persisted routes, seed config/email/dns routes, compute warnings, apply to SmartProxy. */ - public async initialize(): Promise { - await this.loadStoredRoutes(); - await this.loadOverrides(); + public async initialize( + configRoutes: IDcRouterRouteConfig[] = [], + emailRoutes: IDcRouterRouteConfig[] = [], + dnsRoutes: IDcRouterRouteConfig[] = [], + ): Promise { + await this.loadRoutes(); + await this.seedRoutes(configRoutes, 'config'); + await this.seedRoutes(emailRoutes, 'email'); + await this.seedRoutes(dnsRoutes, 'dns'); this.computeWarnings(); this.logWarnings(); await this.applyRoutes(); } // ========================================================================= - // Merged view + // Route listing // ========================================================================= public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } { const merged: IMergedRoute[] = []; - // Hardcoded routes - for (const route of this.getHardcodedRoutes()) { - const name = route.name || ''; - const override = this.overrides.get(name); + for (const route of this.routes.values()) { merged.push({ - route, - source: 'hardcoded', - enabled: override ? override.enabled : true, - overridden: !!override, - }); - } - - // Programmatic routes - for (const stored of this.storedRoutes.values()) { - merged.push({ - route: stored.route, - source: 'programmatic', - enabled: stored.enabled, - overridden: false, - storedRouteId: stored.id, - createdAt: stored.createdAt, - updatedAt: stored.updatedAt, - metadata: stored.metadata, + route: route.route, + id: route.id, + enabled: route.enabled, + origin: route.origin, + createdAt: route.createdAt, + updatedAt: route.updatedAt, + metadata: route.metadata, }); } @@ -113,7 +102,7 @@ export class RouteConfigManager { } // ========================================================================= - // Programmatic route CRUD + // Route CRUD // ========================================================================= public async createRoute( @@ -127,7 +116,7 @@ export class RouteConfigManager { // Ensure route has a name if (!route.name) { - route.name = `programmatic-${id.slice(0, 8)}`; + route.name = `route-${id.slice(0, 8)}`; } // Resolve references if metadata has refs and resolver is available @@ -138,17 +127,18 @@ export class RouteConfigManager { resolvedMetadata = resolved.metadata; } - const stored: IStoredRoute = { + const stored: IRoute = { id, route, enabled, createdAt: now, updatedAt: now, createdBy, + origin: 'api', metadata: resolvedMetadata, }; - this.storedRoutes.set(id, stored); + this.routes.set(id, stored); await this.persistRoute(stored); await this.applyRoutes(); return id; @@ -162,7 +152,7 @@ export class RouteConfigManager { metadata?: Partial; }, ): Promise { - const stored = this.storedRoutes.get(id); + const stored = this.routes.get(id); if (!stored) return false; if (patch.route) { @@ -201,9 +191,9 @@ export class RouteConfigManager { } public async deleteRoute(id: string): Promise { - if (!this.storedRoutes.has(id)) return false; - this.storedRoutes.delete(id); - const doc = await StoredRouteDoc.findById(id); + if (!this.routes.has(id)) return false; + this.routes.delete(id); + const doc = await RouteDoc.findById(id); if (doc) await doc.delete(); await this.applyRoutes(); return true; @@ -214,103 +204,124 @@ export class RouteConfigManager { } // ========================================================================= - // Hardcoded route overrides + // Private: seed routes from constructor config // ========================================================================= - public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise { - const override: IRouteOverride = { - routeName, - enabled, - updatedAt: Date.now(), - updatedBy, - }; - this.overrides.set(routeName, override); - const existingDoc = await RouteOverrideDoc.findByRouteName(routeName); - if (existingDoc) { - existingDoc.enabled = override.enabled; - existingDoc.updatedAt = override.updatedAt; - existingDoc.updatedBy = override.updatedBy; - await existingDoc.save(); - } else { - const doc = new RouteOverrideDoc(); - doc.routeName = override.routeName; - doc.enabled = override.enabled; - doc.updatedAt = override.updatedAt; - doc.updatedBy = override.updatedBy; - await doc.save(); - } - this.computeWarnings(); - await this.applyRoutes(); - } + /** + * Upsert seed routes by name+origin. Preserves user's `enabled` state. + * Deletes stale DB routes whose origin matches but name is not in the seed set. + */ + private async seedRoutes( + seedRoutes: IDcRouterRouteConfig[], + origin: 'config' | 'email' | 'dns', + ): Promise { + if (seedRoutes.length === 0) return; - public async removeOverride(routeName: string): Promise { - if (!this.overrides.has(routeName)) return false; - this.overrides.delete(routeName); - const doc = await RouteOverrideDoc.findByRouteName(routeName); - if (doc) await doc.delete(); - this.computeWarnings(); - await this.applyRoutes(); - return true; + const seedNames = new Set(); + let seeded = 0; + let updated = 0; + + for (const route of seedRoutes) { + const name = route.name || ''; + seedNames.add(name); + + // Check if a route with this name+origin already exists in memory + let existingId: string | undefined; + for (const [id, r] of this.routes) { + if (r.origin === origin && r.route.name === name) { + existingId = id; + break; + } + } + + if (existingId) { + // Update route config but preserve enabled state + const existing = this.routes.get(existingId)!; + existing.route = route; + existing.updatedAt = Date.now(); + await this.persistRoute(existing); + updated++; + } else { + // Insert new seed route + const id = plugins.uuid.v4(); + const now = Date.now(); + const newRoute: IRoute = { + id, + route, + enabled: true, + createdAt: now, + updatedAt: now, + createdBy: 'system', + origin, + }; + this.routes.set(id, newRoute); + await this.persistRoute(newRoute); + seeded++; + } + } + + // Delete stale routes: same origin but name not in current seed set + const staleIds: string[] = []; + for (const [id, r] of this.routes) { + if (r.origin === origin && !seedNames.has(r.route.name || '')) { + staleIds.push(id); + } + } + for (const id of staleIds) { + this.routes.delete(id); + const doc = await RouteDoc.findById(id); + if (doc) await doc.delete(); + } + + if (seeded > 0 || updated > 0 || staleIds.length > 0) { + logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`); + } } // ========================================================================= // Private: persistence // ========================================================================= - private async loadStoredRoutes(): Promise { - const docs = await StoredRouteDoc.findAll(); + private async loadRoutes(): Promise { + const docs = await RouteDoc.findAll(); for (const doc of docs) { if (doc.id) { - this.storedRoutes.set(doc.id, { + this.routes.set(doc.id, { id: doc.id, route: doc.route, enabled: doc.enabled, createdAt: doc.createdAt, updatedAt: doc.updatedAt, createdBy: doc.createdBy, + origin: doc.origin || 'api', metadata: doc.metadata, }); } } - if (this.storedRoutes.size > 0) { - logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`); + if (this.routes.size > 0) { + logger.log('info', `Loaded ${this.routes.size} route(s) from database`); } } - private async loadOverrides(): Promise { - const docs = await RouteOverrideDoc.findAll(); - for (const doc of docs) { - if (doc.routeName) { - this.overrides.set(doc.routeName, { - routeName: doc.routeName, - enabled: doc.enabled, - updatedAt: doc.updatedAt, - updatedBy: doc.updatedBy, - }); - } - } - if (this.overrides.size > 0) { - logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`); - } - } - - private async persistRoute(stored: IStoredRoute): Promise { - const existingDoc = await StoredRouteDoc.findById(stored.id); + private async persistRoute(stored: IRoute): Promise { + const existingDoc = await RouteDoc.findById(stored.id); if (existingDoc) { existingDoc.route = stored.route; existingDoc.enabled = stored.enabled; existingDoc.updatedAt = stored.updatedAt; existingDoc.createdBy = stored.createdBy; + existingDoc.origin = stored.origin; existingDoc.metadata = stored.metadata; await existingDoc.save(); } else { - const doc = new StoredRouteDoc(); + const doc = new RouteDoc(); doc.id = stored.id; doc.route = stored.route; doc.enabled = stored.enabled; doc.createdAt = stored.createdAt; doc.updatedAt = stored.updatedAt; doc.createdBy = stored.createdBy; + doc.origin = stored.origin; doc.metadata = stored.metadata; await doc.save(); } @@ -322,33 +333,14 @@ export class RouteConfigManager { private computeWarnings(): void { this.warnings = []; - const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || '')); - // Check overrides - for (const [routeName, override] of this.overrides) { - if (!hardcodedNames.has(routeName)) { + for (const route of this.routes.values()) { + if (!route.enabled) { + const name = route.route.name || route.id; this.warnings.push({ - type: 'orphaned-override', - routeName, - message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`, - }); - } else if (!override.enabled) { - this.warnings.push({ - type: 'disabled-hardcoded', - routeName, - message: `Route '${routeName}' is disabled via API override`, - }); - } - } - - // Check disabled programmatic routes - for (const stored of this.storedRoutes.values()) { - if (!stored.enabled) { - const name = stored.route.name || stored.id; - this.warnings.push({ - type: 'disabled-programmatic', + type: 'disabled-route', routeName: name, - message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`, + message: `Route '${name}' (id: ${route.id}) is disabled`, }); } } @@ -372,7 +364,7 @@ export class RouteConfigManager { if (!this.referenceResolver || routeIds.length === 0) return; for (const routeId of routeIds) { - const stored = this.storedRoutes.get(routeId); + const stored = this.routes.get(routeId); if (!stored?.metadata) continue; const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); @@ -387,7 +379,7 @@ export class RouteConfigManager { } // ========================================================================= - // Private: apply merged routes to SmartProxy + // Apply routes to SmartProxy // ========================================================================= public async applyRoutes(): Promise { @@ -416,35 +408,25 @@ export class RouteConfigManager { }; }; - // Add enabled hardcoded routes (respecting overrides, with fresh VPN injection) - for (const route of this.getHardcodedRoutes()) { - const name = route.name || ''; - const override = this.overrides.get(name); - if (override && !override.enabled) { - continue; // Skip disabled hardcoded route - } - enabledRoutes.push(injectVpn(route)); - } - - // Add enabled programmatic routes (with HTTP/3 and VPN augmentation) - for (const stored of this.storedRoutes.values()) { - if (stored.enabled) { - let route = stored.route; + // Add all enabled routes with HTTP/3 and VPN augmentation + for (const route of this.routes.values()) { + if (route.enabled) { + let r = route.route; if (http3Config?.enabled !== false) { - route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); + r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config }); } - enabledRoutes.push(injectVpn(route, stored.id)); + enabledRoutes.push(injectVpn(r, route.id)); } } await smartProxy.updateRoutes(enabledRoutes); - // Notify listeners (e.g. RemoteIngressManager) of the merged route set + // Notify listeners (e.g. RemoteIngressManager) of the route set if (this.onRoutesApplied) { this.onRoutesApplied(enabledRoutes); } - logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`); + logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`); }); } } diff --git a/ts/config/classes.target-profile-manager.ts b/ts/config/classes.target-profile-manager.ts index 802e183..53e8d5c 100644 --- a/ts/config/classes.target-profile-manager.ts +++ b/ts/config/classes.target-profile-manager.ts @@ -3,7 +3,7 @@ import { logger } from '../logger.js'; import { TargetProfileDoc, VpnClientDoc } from '../db/index.js'; import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; -import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'; +import type { IRoute } from '../../ts_interfaces/data/route-management.js'; /** * Manages TargetProfiles (target-side: what can be accessed). @@ -220,8 +220,7 @@ export class TargetProfileManager { */ public getClientAccessSpec( targetProfileIds: string[], - allRoutes: IDcRouterRouteConfig[], - storedRoutes: Map, + allRoutes: Map, ): { domains: string[]; targetIps: string[] } { const domains = new Set(); const targetIps = new Set(); @@ -245,23 +244,11 @@ export class TargetProfileManager { } } - // Route references: scan constructor routes - for (const route of allRoutes) { - if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) { - const routeDomains = (route.match as any)?.domains; - if (Array.isArray(routeDomains)) { - for (const d of routeDomains) { - domains.add(d); - } - } - } - } - - // Route references: scan stored routes - for (const [storedId, stored] of storedRoutes) { - if (!stored.enabled) continue; - if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) { - const routeDomains = (stored.route.match as any)?.domains; + // Route references: scan all routes + for (const [routeId, route] of allRoutes) { + if (!route.enabled) continue; + if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) { + const routeDomains = (route.route.match as any)?.domains; if (Array.isArray(routeDomains)) { for (const d of routeDomains) { domains.add(d); diff --git a/ts/db/documents/classes.route-override.doc.ts b/ts/db/documents/classes.route-override.doc.ts deleted file mode 100644 index 30221b6..0000000 --- a/ts/db/documents/classes.route-override.doc.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { DcRouterDb } from '../classes.dcrouter-db.js'; - -const getDb = () => DcRouterDb.getInstance().getDb(); - -@plugins.smartdata.Collection(() => getDb()) -export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc { - @plugins.smartdata.unI() - @plugins.smartdata.svDb() - public routeName!: string; - - @plugins.smartdata.svDb() - public enabled!: boolean; - - @plugins.smartdata.svDb() - public updatedAt!: number; - - @plugins.smartdata.svDb() - public updatedBy!: string; - - constructor() { - super(); - } - - public static async findByRouteName(routeName: string): Promise { - return await RouteOverrideDoc.getInstance({ routeName }); - } - - public static async findAll(): Promise { - return await RouteOverrideDoc.getInstances({}); - } -} diff --git a/ts/db/documents/classes.stored-route.doc.ts b/ts/db/documents/classes.route.doc.ts similarity index 55% rename from ts/db/documents/classes.stored-route.doc.ts rename to ts/db/documents/classes.route.doc.ts index cbc2dff..811505f 100644 --- a/ts/db/documents/classes.stored-route.doc.ts +++ b/ts/db/documents/classes.route.doc.ts @@ -6,7 +6,7 @@ import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteing const getDb = () => DcRouterDb.getInstance().getDb(); @plugins.smartdata.Collection(() => getDb()) -export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc { +export class RouteDoc extends plugins.smartdata.SmartDataDbDoc { @plugins.smartdata.unI() @plugins.smartdata.svDb() public id!: string; @@ -26,6 +26,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc { - return await StoredRouteDoc.getInstance({ id }); + public static async findById(id: string): Promise { + return await RouteDoc.getInstance({ id }); } - public static async findAll(): Promise { - return await StoredRouteDoc.getInstances({}); + public static async findAll(): Promise { + return await RouteDoc.getInstances({}); + } + + public static async findByName(name: string): Promise { + return await RouteDoc.getInstance({ 'route.name': name }); + } + + public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise { + return await RouteDoc.getInstances({ origin }); } } diff --git a/ts/db/documents/index.ts b/ts/db/documents/index.ts index 16933b5..8e7c6b0 100644 --- a/ts/db/documents/index.ts +++ b/ts/db/documents/index.ts @@ -3,8 +3,7 @@ export * from './classes.cached.email.js'; export * from './classes.cached.ip.reputation.js'; // Config document classes -export * from './classes.stored-route.doc.js'; -export * from './classes.route-override.doc.js'; +export * from './classes.route.doc.js'; export * from './classes.api-token.doc.js'; export * from './classes.source-profile.doc.js'; export * from './classes.target-profile.doc.js'; diff --git a/ts/opsserver/handlers/network-target.handler.ts b/ts/opsserver/handlers/network-target.handler.ts index 6f08bd7..38d1efa 100644 --- a/ts/opsserver/handlers/network-target.handler.ts +++ b/ts/opsserver/handlers/network-target.handler.ts @@ -135,7 +135,7 @@ export class NetworkTargetHandler { const result = await resolver.deleteTarget( dataArg.id, dataArg.force ?? false, - manager.getStoredRoutes(), + manager.getRoutes(), ); if (result.success && dataArg.force) { @@ -158,7 +158,7 @@ export class NetworkTargetHandler { if (!resolver || !manager) { return { routes: [] }; } - const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes()); + const usage = resolver.getTargetUsageForId(dataArg.id, manager.getRoutes()); return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) }; }, ), diff --git a/ts/opsserver/handlers/route-management.handler.ts b/ts/opsserver/handlers/route-management.handler.ts index 0502cf4..d8d9943 100644 --- a/ts/opsserver/handlers/route-management.handler.ts +++ b/ts/opsserver/handlers/route-management.handler.ts @@ -72,7 +72,7 @@ export class RouteManagementHandler { return { success: false, message: 'Route management not initialized' }; } const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata); - return { success: true, storedRouteId: id }; + return { success: true, routeId: id }; }, ), ); @@ -113,39 +113,7 @@ export class RouteManagementHandler { ), ); - // Set override on a hardcoded route - this.typedrouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( - 'setRouteOverride', - async (dataArg) => { - const userId = await this.requireAuth(dataArg, 'routes:write'); - const manager = this.opsServerRef.dcRouterRef.routeConfigManager; - if (!manager) { - return { success: false, message: 'Route management not initialized' }; - } - await manager.setOverride(dataArg.routeName, dataArg.enabled, userId); - return { success: true }; - }, - ), - ); - - // Remove override from a hardcoded route - this.typedrouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( - 'removeRouteOverride', - async (dataArg) => { - await this.requireAuth(dataArg, 'routes:write'); - const manager = this.opsServerRef.dcRouterRef.routeConfigManager; - if (!manager) { - return { success: false, message: 'Route management not initialized' }; - } - const ok = await manager.removeOverride(dataArg.routeName); - return { success: ok, message: ok ? undefined : 'Override not found' }; - }, - ), - ); - - // Toggle programmatic route + // Toggle route this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'toggleRoute', diff --git a/ts/opsserver/handlers/source-profile.handler.ts b/ts/opsserver/handlers/source-profile.handler.ts index e237044..be90832 100644 --- a/ts/opsserver/handlers/source-profile.handler.ts +++ b/ts/opsserver/handlers/source-profile.handler.ts @@ -136,7 +136,7 @@ export class SourceProfileHandler { const result = await resolver.deleteProfile( dataArg.id, dataArg.force ?? false, - manager.getStoredRoutes(), + manager.getRoutes(), ); // If force-deleted with affected routes, re-apply @@ -160,7 +160,7 @@ export class SourceProfileHandler { if (!resolver || !manager) { return { routes: [] }; } - const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes()); + const usage = resolver.getProfileUsageForId(dataArg.id, manager.getRoutes()); return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) }; }, ), diff --git a/ts_apiclient/classes.route.ts b/ts_apiclient/classes.route.ts index ca7ff57..af5270e 100644 --- a/ts_apiclient/classes.route.ts +++ b/ts_apiclient/classes.route.ts @@ -7,10 +7,9 @@ export class Route { // Data from IMergedRoute public routeConfig: IRouteConfig; - public source: 'hardcoded' | 'programmatic'; + public id: string; public enabled: boolean; - public overridden: boolean; - public storedRouteId?: string; + public origin: 'config' | 'email' | 'dns' | 'api'; public createdAt?: number; public updatedAt?: number; @@ -22,21 +21,17 @@ export class Route { constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) { this.clientRef = clientRef; this.routeConfig = data.route; - this.source = data.source; + this.id = data.id; this.enabled = data.enabled; - this.overridden = data.overridden; - this.storedRouteId = data.storedRouteId; + this.origin = data.origin; this.createdAt = data.createdAt; this.updatedAt = data.updatedAt; } public async update(changes: Partial): Promise { - if (!this.storedRouteId) { - throw new Error('Cannot update a hardcoded route. Use setOverride() instead.'); - } const response = await this.clientRef.request( 'updateRoute', - this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any, + this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any, ); if (!response.success) { throw new Error(response.message || 'Failed to update route'); @@ -44,12 +39,9 @@ export class Route { } public async delete(): Promise { - if (!this.storedRouteId) { - throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.'); - } const response = await this.clientRef.request( 'deleteRoute', - this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any, + this.clientRef.buildRequestPayload({ id: this.id }) as any, ); if (!response.success) { throw new Error(response.message || 'Failed to delete route'); @@ -57,41 +49,15 @@ export class Route { } public async toggle(enabled: boolean): Promise { - if (!this.storedRouteId) { - throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.'); - } const response = await this.clientRef.request( 'toggleRoute', - this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any, + this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any, ); if (!response.success) { throw new Error(response.message || 'Failed to toggle route'); } this.enabled = enabled; } - - public async setOverride(enabled: boolean): Promise { - const response = await this.clientRef.request( - 'setRouteOverride', - this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any, - ); - if (!response.success) { - throw new Error(response.message || 'Failed to set route override'); - } - this.overridden = true; - this.enabled = enabled; - } - - public async removeOverride(): Promise { - const response = await this.clientRef.request( - 'removeRouteOverride', - this.clientRef.buildRequestPayload({ routeName: this.name }) as any, - ); - if (!response.success) { - throw new Error(response.message || 'Failed to remove route override'); - } - this.overridden = false; - } } export class RouteBuilder { @@ -144,9 +110,8 @@ export class RouteBuilder { } // Return a Route instance by re-fetching the list - // The created route is programmatic, so we find it by storedRouteId const { routes } = await new RouteManager(this.clientRef).list(); - const created = routes.find((r) => r.storedRouteId === response.storedRouteId); + const created = routes.find((r) => r.id === response.routeId); if (created) { return created; } @@ -154,10 +119,9 @@ export class RouteBuilder { // Fallback: construct from known data return new Route(this.clientRef, { route: this.routeConfig as IRouteConfig, - source: 'programmatic', + id: response.routeId || '', enabled: this.isEnabled, - overridden: false, - storedRouteId: response.storedRouteId, + origin: 'api', }); } } @@ -190,10 +154,9 @@ export class RouteManager { } return new Route(this.clientRef, { route: routeConfig, - source: 'programmatic', + id: response.routeId || '', enabled: enabled ?? true, - overridden: false, - storedRouteId: response.storedRouteId, + origin: 'api', }); } diff --git a/ts_interfaces/data/route-management.ts b/ts_interfaces/data/route-management.ts index 4d6f178..efbf71c 100644 --- a/ts_interfaces/data/route-management.ts +++ b/ts_interfaces/data/route-management.ts @@ -83,24 +83,23 @@ export interface IRouteMetadata { } /** - * A merged route combining hardcoded and programmatic sources. + * A route entry returned by the route management API. */ export interface IMergedRoute { route: IDcRouterRouteConfig; - source: 'hardcoded' | 'programmatic'; + id: string; enabled: boolean; - overridden: boolean; - storedRouteId?: string; + origin: 'config' | 'email' | 'dns' | 'api'; createdAt?: number; updatedAt?: number; metadata?: IRouteMetadata; } /** - * A warning generated during route merge/startup. + * A warning generated during route startup/apply. */ export interface IRouteWarning { - type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override'; + type: 'disabled-route'; routeName: string; message: string; } @@ -123,28 +122,19 @@ export interface IApiTokenInfo { // ============================================================================ /** - * A programmatic route stored in /config-api/routes/{id}.json + * A route persisted in the database. */ -export interface IStoredRoute { +export interface IRoute { id: string; route: IDcRouterRouteConfig; enabled: boolean; createdAt: number; updatedAt: number; createdBy: string; + origin: 'config' | 'email' | 'dns' | 'api'; metadata?: IRouteMetadata; } -/** - * An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json - */ -export interface IRouteOverride { - routeName: string; - enabled: boolean; - updatedAt: number; - updatedBy: string; -} - /** * A stored API token, stored in /config-api/tokens/{id}.json */ diff --git a/ts_interfaces/requests/route-management.ts b/ts_interfaces/requests/route-management.ts index c87e6b5..ffbb2ab 100644 --- a/ts_interfaces/requests/route-management.ts +++ b/ts_interfaces/requests/route-management.ts @@ -9,7 +9,7 @@ import type { IDcRouterRouteConfig } from '../data/remoteingress.js'; // ============================================================================ /** - * Get all merged routes (hardcoded + programmatic) with warnings. + * Get all routes with warnings. */ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, @@ -27,7 +27,7 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp } /** - * Create a new programmatic route. + * Create a new route. */ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, @@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme }; response: { success: boolean; - storedRouteId?: string; + routeId?: string; message?: string; }; } /** - * Update a programmatic route. + * Update a route. */ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, @@ -71,7 +71,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme } /** - * Delete a programmatic route. + * Delete a route. */ export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, @@ -90,46 +90,7 @@ export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.impleme } /** - * Set an override on a hardcoded route (disable/enable by name). - */ -export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR< - plugins.typedrequestInterfaces.ITypedRequest, - IReq_SetRouteOverride -> { - method: 'setRouteOverride'; - request: { - identity?: authInterfaces.IIdentity; - apiToken?: string; - routeName: string; - enabled: boolean; - }; - response: { - success: boolean; - message?: string; - }; -} - -/** - * Remove an override from a hardcoded route (restore default behavior). - */ -export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR< - plugins.typedrequestInterfaces.ITypedRequest, - IReq_RemoveRouteOverride -> { - method: 'removeRouteOverride'; - request: { - identity?: authInterfaces.IIdentity; - apiToken?: string; - routeName: string; - }; - response: { - success: boolean; - message?: string; - }; -} - -/** - * Toggle a programmatic route on/off by id. + * Toggle a route on/off by id. */ export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, diff --git a/ts_migrations/index.ts b/ts_migrations/index.ts index d1a33db..35e517f 100644 --- a/ts_migrations/index.ts +++ b/ts_migrations/index.ts @@ -92,6 +92,34 @@ export async function createMigrationRunner( 'info', `rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`, ); + }) + .step('unify-routes-rename-collection') + .from('13.8.2').to('13.16.0') + .description('Rename storedroutedoc → routedoc, add origin field, drop routeoverridedoc') + .up(async (ctx) => { + const db = ctx.mongo!; + + // 1. Rename storedroutedoc → routedoc + const collections = await db.listCollections({ name: 'storedroutedoc' }).toArray(); + if (collections.length > 0) { + await db.renameCollection('storedroutedoc', 'routedoc'); + ctx.log.log('info', 'Renamed storedroutedoc → routedoc'); + } + + // 2. Set origin='api' on all migrated docs (they were API-created) + const routeCol = db.collection('routedoc'); + const result = await routeCol.updateMany( + { origin: { $exists: false } }, + { $set: { origin: 'api' } }, + ); + ctx.log.log('info', `Set origin='api' on ${result.modifiedCount} migrated route(s)`); + + // 3. Drop routeoverridedoc collection + const overrideCollections = await db.listCollections({ name: 'routeoverridedoc' }).toArray(); + if (overrideCollections.length > 0) { + await db.collection('routeoverridedoc').drop(); + ctx.log.log('info', 'Dropped routeoverridedoc collection'); + } }); return migration; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 8b12c34..27ca71a 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.15.1', + version: '13.16.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 48927d1..d3c2d64 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -2219,58 +2219,6 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{ } }); -export const setRouteOverrideAction = routeManagementStatePart.createAction<{ - routeName: string; - enabled: boolean; -}>(async (statePartArg, dataArg, actionContext): Promise => { - const context = getActionContext(); - const currentState = statePartArg.getState()!; - - try { - const request = new plugins.domtools.plugins.typedrequest.TypedRequest< - interfaces.requests.IReq_SetRouteOverride - >('/typedrequest', 'setRouteOverride'); - - await request.fire({ - identity: context.identity!, - routeName: dataArg.routeName, - enabled: dataArg.enabled, - }); - - return await actionContext!.dispatch(fetchMergedRoutesAction, null); - } catch (error: unknown) { - return { - ...currentState, - error: error instanceof Error ? error.message : 'Failed to set override', - }; - } -}); - -export const removeRouteOverrideAction = routeManagementStatePart.createAction( - async (statePartArg, routeName, actionContext): Promise => { - const context = getActionContext(); - const currentState = statePartArg.getState()!; - - try { - const request = new plugins.domtools.plugins.typedrequest.TypedRequest< - interfaces.requests.IReq_RemoveRouteOverride - >('/typedrequest', 'removeRouteOverride'); - - await request.fire({ - identity: context.identity!, - routeName, - }); - - return await actionContext!.dispatch(fetchMergedRoutesAction, null); - } catch (error: unknown) { - return { - ...currentState, - error: error instanceof Error ? error.message : 'Failed to remove override', - }; - } - } -); - // ============================================================================ // API Token Actions // ============================================================================ diff --git a/ts_web/elements/network/ops-view-routes.ts b/ts_web/elements/network/ops-view-routes.ts index 3ac81b2..a1153e2 100644 --- a/ts_web/elements/network/ops-view-routes.ts +++ b/ts_web/elements/network/ops-view-routes.ts @@ -140,9 +140,9 @@ export class OpsViewRoutes extends DeesElement { public render(): TemplateResult { const { mergedRoutes, warnings } = this.routeState; - const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length; - const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length; const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length; + const configCount = mergedRoutes.filter((mr) => mr.origin !== 'api').length; + const apiCount = mergedRoutes.filter((mr) => mr.origin === 'api').length; const statsTiles: IStatsTile[] = [ { @@ -155,19 +155,19 @@ export class OpsViewRoutes extends DeesElement { color: '#3b82f6', }, { - id: 'hardcoded', - title: 'Hardcoded', + id: 'configRoutes', + title: 'From Config', type: 'number', - value: hardcodedCount, - icon: 'lucide:lock', - description: 'Routes from constructor config', + value: configCount, + icon: 'lucide:settings', + description: 'Seeded from config/email/DNS', color: '#8b5cf6', }, { - id: 'programmatic', - title: 'Programmatic', + id: 'apiRoutes', + title: 'API Created', type: 'number', - value: programmaticCount, + value: apiCount, icon: 'lucide:code', description: 'Routes added via API', color: '#0ea5e9', @@ -186,15 +186,14 @@ export class OpsViewRoutes extends DeesElement { // Map merged routes to sz-route-list-view format const szRoutes = mergedRoutes.map((mr) => { const tags = [...(mr.route.tags || [])]; - tags.push(mr.source); + tags.push(mr.origin); if (!mr.enabled) tags.push('disabled'); - if (mr.overridden) tags.push('overridden'); return { ...mr.route, enabled: mr.enabled, tags, - id: mr.storedRouteId || mr.route.name || undefined, + id: mr.id || mr.route.name || undefined, metadata: mr.metadata, }; }); @@ -238,7 +237,6 @@ export class OpsViewRoutes extends DeesElement { ? html` route.tags?.includes('programmatic') ?? false} @route-click=${(e: CustomEvent) => this.handleRouteClick(e)} @route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)} @route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)} @@ -247,7 +245,7 @@ export class OpsViewRoutes extends DeesElement { : html`

No routes configured

-

Add a programmatic route or check your constructor configuration.

+

Add a route to get started.

`} @@ -266,112 +264,56 @@ export class OpsViewRoutes extends DeesElement { const { DeesModal } = await import('@design.estate/dees-catalog'); - if (merged.source === 'hardcoded') { - const menuOptions = merged.enabled - ? [ - { - name: 'Disable Route', - iconName: 'lucide:pause', - action: async (modalArg: any) => { - await appstate.routeManagementStatePart.dispatchAction( - appstate.setRouteOverrideAction, - { routeName: merged.route.name!, enabled: false }, - ); - await modalArg.destroy(); - }, - }, - { - name: 'Close', - iconName: 'lucide:x', - action: async (modalArg: any) => await modalArg.destroy(), - }, - ] - : [ - { - name: 'Enable Route', - iconName: 'lucide:play', - action: async (modalArg: any) => { - await appstate.routeManagementStatePart.dispatchAction( - appstate.setRouteOverrideAction, - { routeName: merged.route.name!, enabled: true }, - ); - await modalArg.destroy(); - }, - }, - { - name: 'Remove Override', - iconName: 'lucide:undo', - action: async (modalArg: any) => { - await appstate.routeManagementStatePart.dispatchAction( - appstate.removeRouteOverrideAction, - merged.route.name!, - ); - await modalArg.destroy(); - }, - }, - { - name: 'Close', - iconName: 'lucide:x', - action: async (modalArg: any) => await modalArg.destroy(), - }, - ]; - - await DeesModal.createAndShow({ - heading: `Route: ${merged.route.name}`, - content: html` -
-

Source: hardcoded

-

Status: ${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}

-

Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.

-
- `, - menuOptions, - }); - } else { - // Programmatic route - const meta = merged.metadata; - await DeesModal.createAndShow({ - heading: `Route: ${merged.route.name}`, - content: html` -
-

Source: programmatic

-

Status: ${merged.enabled ? 'Enabled' : 'Disabled'}

-

ID: ${merged.storedRouteId}

- ${meta?.sourceProfileName ? html`

Source Profile: ${meta.sourceProfileName}

` : ''} - ${meta?.networkTargetName ? html`

Network Target: ${meta.networkTargetName}

` : ''} -
- `, - menuOptions: [ - { - name: merged.enabled ? 'Disable' : 'Enable', - iconName: merged.enabled ? 'lucide:pause' : 'lucide:play', - action: async (modalArg: any) => { - await appstate.routeManagementStatePart.dispatchAction( - appstate.toggleRouteAction, - { id: merged.storedRouteId!, enabled: !merged.enabled }, - ); - await modalArg.destroy(); - }, + const meta = merged.metadata; + await DeesModal.createAndShow({ + heading: `Route: ${merged.route.name}`, + content: html` +
+

Origin: ${merged.origin}

+

Status: ${merged.enabled ? 'Enabled' : 'Disabled'}

+

ID: ${merged.id}

+ ${meta?.sourceProfileName ? html`

Source Profile: ${meta.sourceProfileName}

` : ''} + ${meta?.networkTargetName ? html`

Network Target: ${meta.networkTargetName}

` : ''} +
+ `, + menuOptions: [ + { + name: merged.enabled ? 'Disable' : 'Enable', + iconName: merged.enabled ? 'lucide:pause' : 'lucide:play', + action: async (modalArg: any) => { + await appstate.routeManagementStatePart.dispatchAction( + appstate.toggleRouteAction, + { id: merged.id, enabled: !merged.enabled }, + ); + await modalArg.destroy(); }, - { - name: 'Delete', - iconName: 'lucide:trash-2', - action: async (modalArg: any) => { - await appstate.routeManagementStatePart.dispatchAction( - appstate.deleteRouteAction, - merged.storedRouteId!, - ); - await modalArg.destroy(); - }, + }, + { + name: 'Edit', + iconName: 'lucide:pencil', + action: async (modalArg: any) => { + await modalArg.destroy(); + this.showEditRouteDialog(merged); }, - { - name: 'Close', - iconName: 'lucide:x', - action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Delete', + iconName: 'lucide:trash-2', + action: async (modalArg: any) => { + await appstate.routeManagementStatePart.dispatchAction( + appstate.deleteRouteAction, + merged.id, + ); + await modalArg.destroy(); }, - ], - }); - } + }, + { + name: 'Close', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + ], + }); } private async handleRouteEdit(e: CustomEvent) { @@ -381,7 +323,7 @@ export class OpsViewRoutes extends DeesElement { const merged = this.routeState.mergedRoutes.find( (mr) => mr.route.name === clickedRoute.name, ); - if (!merged || !merged.storedRouteId) return; + if (!merged) return; this.showEditRouteDialog(merged); } @@ -393,7 +335,7 @@ export class OpsViewRoutes extends DeesElement { const merged = this.routeState.mergedRoutes.find( (mr) => mr.route.name === clickedRoute.name, ); - if (!merged || !merged.storedRouteId) return; + if (!merged) return; const { DeesModal } = await import('@design.estate/dees-catalog'); await DeesModal.createAndShow({ @@ -415,7 +357,7 @@ export class OpsViewRoutes extends DeesElement { action: async (modalArg: any) => { await appstate.routeManagementStatePart.dispatchAction( appstate.deleteRouteAction, - merged.storedRouteId!, + merged.id, ); await modalArg.destroy(); }, @@ -563,7 +505,7 @@ export class OpsViewRoutes extends DeesElement { await appstate.routeManagementStatePart.dispatchAction( appstate.updateRouteAction, { - id: merged.storedRouteId!, + id: merged.id, route: updatedRoute, metadata: Object.keys(metadata).length > 0 ? metadata : undefined, }, @@ -603,7 +545,7 @@ export class OpsViewRoutes extends DeesElement { ]; const createModal = await DeesModal.createAndShow({ - heading: 'Add Programmatic Route', + heading: 'Add Route', content: html`