diff --git a/.playwright-mcp/console-2026-02-23T10-44-24-024Z.log b/.playwright-mcp/console-2026-02-23T10-44-24-024Z.log new file mode 100644 index 0000000..3df6ff1 --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T10-44-24-024Z.log @@ -0,0 +1,7 @@ +[ 74ms] TypeError: Cannot read properties of null (reading 'appendChild') + at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21) + at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10) + at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23) + at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9) +[ 587ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13 +[ 697ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0 diff --git a/.playwright-mcp/console-2026-02-23T11-19-21-255Z.log b/.playwright-mcp/console-2026-02-23T11-19-21-255Z.log new file mode 100644 index 0000000..25020bd --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T11-19-21-255Z.log @@ -0,0 +1,12 @@ +[ 669ms] [WARNING] Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information. @ http://localhost:3000/chunk-3L5NJTXF.js:13541 +[ 729ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0 +[ 27973ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115 +[ 27973ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141 +[ 29975ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115 +[ 29975ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141 +[ 33977ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115 +[ 33978ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141 +[ 41980ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115 +[ 41980ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141 +[ 51983ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115 +[ 51983ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141 diff --git a/.playwright-mcp/console-2026-02-23T11-20-31-682Z.log b/.playwright-mcp/console-2026-02-23T11-20-31-682Z.log new file mode 100644 index 0000000..cc2ba6d --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T11-20-31-682Z.log @@ -0,0 +1,6 @@ +[ 55ms] TypeError: Cannot read properties of null (reading 'appendChild') + at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21) + at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10) + at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23) + at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9) +[ 791ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0 diff --git a/.playwright-mcp/console-2026-02-23T11-21-09-382Z.log b/.playwright-mcp/console-2026-02-23T11-21-09-382Z.log new file mode 100644 index 0000000..92713c4 --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T11-21-09-382Z.log @@ -0,0 +1,50 @@ +[ 272ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) + at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203 +[ 272ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 274ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Pause-circle + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) + at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203 +[ 274ms] [WARNING] Lucide icon 'Pause-circle' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 275ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 275ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) + at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203 +[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 297ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13 +[ 377ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0 +[ 78064ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13 +[ 78237ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0 +[ 127969ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227 +[ 127969ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251 +[ 129695ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227 +[ 129695ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251 +[ 133309ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227 +[ 133309ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251 +[ 141762ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13 +[ 141910ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0 diff --git a/.playwright-mcp/console-2026-02-23T11-23-44-606Z.log b/.playwright-mcp/console-2026-02-23T11-23-44-606Z.log new file mode 100644 index 0000000..37bb7d2 --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T11-23-44-606Z.log @@ -0,0 +1,23 @@ +[ 437ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0 +[ 38948ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203 +[ 52895ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 52896ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 52896ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 52897ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 99401ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 99401ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 diff --git a/.playwright-mcp/page-2026-02-23T11-25-39-255Z.png b/.playwright-mcp/page-2026-02-23T11-25-39-255Z.png new file mode 100644 index 0000000..89b0ea5 Binary files /dev/null and b/.playwright-mcp/page-2026-02-23T11-25-39-255Z.png differ diff --git a/.playwright-mcp/page-2026-02-23T11-26-10-952Z.png b/.playwright-mcp/page-2026-02-23T11-26-10-952Z.png new file mode 100644 index 0000000..ba9b23b Binary files /dev/null and b/.playwright-mcp/page-2026-02-23T11-26-10-952Z.png differ diff --git a/.playwright-mcp/page-2026-02-23T11-26-15-885Z.png b/.playwright-mcp/page-2026-02-23T11-26-15-885Z.png new file mode 100644 index 0000000..ba9b23b Binary files /dev/null and b/.playwright-mcp/page-2026-02-23T11-26-15-885Z.png differ diff --git a/changelog.md b/changelog.md index 347ed0a..a2bbfbb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2026-02-23 - 8.1.0 - feat(route-management) +add programmatic route management API with API tokens and admin UI + +- Introduce RouteConfigManager to persist and manage programmatic routes and hardcoded-route overrides +- Add ApiTokenManager to create, validate, list, toggle and revoke API tokens (stored hashed) +- New OpsServer TypedRequest handlers: RouteManagementHandler (getMergedRoutes, create/update/delete/toggle routes, set/remove overrides) and ApiTokenHandler (create/list/revoke/toggle tokens) +- DcRouter integration: initialize routeConfigManager and apiTokenManager, expose getConstructorRoutes and re-apply programmatic routes after SmartProxy restarts +- Front-end additions: new 'Routes' and 'ApiTokens' views and UI components (ops-view-routes, ops-view-apitokens), router and appstate actions to fetch/manage routes and tokens +- New TS interfaces and request types for route-management and API tokens, plus storage schemas for persisted routes, overrides and tokens +- Bump dependency @serve.zone/catalog to ^2.3.0 + ## 2026-02-22 - 8.0.0 - BREAKING CHANGE(email-ops) migrate email operations to catalog-compatible email model and simplify UI/router diff --git a/package.json b/package.json index e3f604a..1807398 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartstate": "^2.0.30", "@push.rocks/smartunique": "^3.0.9", - "@serve.zone/catalog": "^2.2.0", + "@serve.zone/catalog": "^2.3.0", "@serve.zone/interfaces": "^5.3.0", "@serve.zone/remoteingress": "^4.0.0", "@tsclass/tsclass": "^9.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12799e5..69231f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^3.0.9 version: 3.0.9 '@serve.zone/catalog': - specifier: ^2.2.0 - version: 2.2.0(@tiptap/pm@2.27.2) + specifier: ^2.3.0 + version: 2.3.0(@tiptap/pm@2.27.2) '@serve.zone/interfaces': specifier: ^5.3.0 version: 5.3.0 @@ -1333,8 +1333,8 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@serve.zone/catalog@2.2.0': - resolution: {integrity: sha512-FxRGjuz8PdOXnfjHAGuPWP4jUTVGl5r9rsnxZlGSgTT+dHAm6Ue9AoTCkwVTKV9hP/Ac4yy8KKeNtNYIlidfJQ==} + '@serve.zone/catalog@2.3.0': + resolution: {integrity: sha512-KCIQZXBO5A93VIsRkI/UzApNImEHzuA7P3Wx33+mDVUZ8/I5hafuCLgPzNu1q/TgQUte+q6I6e5Erezc9Hn74Q==} '@serve.zone/interfaces@5.3.0': resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} @@ -6785,7 +6785,7 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 - '@serve.zone/catalog@2.2.0(@tiptap/pm@2.27.2)': + '@serve.zone/catalog@2.3.0(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-catalog': 3.43.2(@tiptap/pm@2.27.2) '@design.estate/dees-domtools': 2.3.8 diff --git a/test_watch/devserver.ts b/test_watch/devserver.ts index 370ec76..de296ef 100644 --- a/test_watch/devserver.ts +++ b/test_watch/devserver.ts @@ -1,21 +1,32 @@ import { DcRouter } from '../ts/index.js'; const devRouter = new DcRouter({ - // Configure services as needed for development - // OpsServer always starts on port 3000 - - // Example: Add SmartProxy routes - // smartProxyConfig: { - // routes: [...] - // }, - - // Example: Add email configuration - // emailConfig: { - // ports: [2525], - // hostname: 'localhost', - // domains: [], - // routes: [] - // }, + // SmartProxy routes for development/demo + smartProxyConfig: { + routes: [ + { + name: 'web-traffic', + match: { ports: [18080], domains: ['example.com', '*.example.com'] }, + action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] }, + }, + { + name: 'api-gateway', + match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' }, + action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] }, + }, + { + name: 'tls-passthrough', + match: { ports: [18443], domains: ['secure.example.com'] }, + action: { + type: 'forward', + targets: [{ host: 'localhost', port: 4443 }], + tls: { mode: 'passthrough' }, + }, + }, + ], + }, + // Disable cache/mongo for dev + cacheConfig: { enabled: false }, }); console.log('Starting DcRouter in development mode...'); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 32d755a..869f310 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: '8.0.0', + version: '8.1.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 7c32156..c98fa1c 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js'; import { MetricsManager } from './monitoring/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; +import { RouteConfigManager, ApiTokenManager } from './config/index.js'; export interface IDcRouterOptions { /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ @@ -212,6 +213,10 @@ export class DcRouter { public remoteIngressManager?: RemoteIngressManager; public tunnelManager?: TunnelManager; + // Programmatic config API + public routeConfigManager?: RouteConfigManager; + public apiTokenManager?: ApiTokenManager; + // DNS query logging rate limiter state private dnsLogWindow: number[] = []; private dnsBatchCount: number = 0; @@ -233,6 +238,9 @@ 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[] = []; + // Environment access private qenv = new plugins.qenv.Qenv('./', '.nogit/'); @@ -275,7 +283,17 @@ export class DcRouter { // Set up SmartProxy for HTTP/HTTPS and all traffic including email routes await this.setupSmartProxy(); - + + // Initialize programmatic config API managers + this.routeConfigManager = new RouteConfigManager( + this.storageManager, + () => this.getConstructorRoutes(), + () => this.smartProxy, + ); + this.apiTokenManager = new ApiTokenManager(this.storageManager); + await this.apiTokenManager.initialize(); + await this.routeConfigManager.initialize(); + // Set up unified email handling if configured if (this.options.emailConfig) { await this.setupUnifiedEmailHandling(); @@ -443,6 +461,9 @@ export class DcRouter { challengeHandlers.push(dns01Handler); } + // Cache constructor routes for RouteConfigManager + 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'); @@ -857,6 +878,14 @@ 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...'); @@ -929,6 +958,8 @@ export class DcRouter { this.smartAcme = undefined; this.certProvisionScheduler = undefined; this.remoteIngressManager = undefined; + this.routeConfigManager = undefined; + this.apiTokenManager = undefined; this.certificateStatusMap.clear(); logger.log('info', 'All DcRouter services stopped'); @@ -960,6 +991,11 @@ export class DcRouter { // Start new SmartProxy with updated configuration (will include email routes if configured) await this.setupSmartProxy(); + // Re-apply programmatic routes and overrides after SmartProxy restart + if (this.routeConfigManager) { + await this.routeConfigManager.initialize(); + } + logger.log('info', 'SmartProxy configuration updated'); } diff --git a/ts/config/classes.api-token-manager.ts b/ts/config/classes.api-token-manager.ts new file mode 100644 index 0000000..d778b12 --- /dev/null +++ b/ts/config/classes.api-token-manager.ts @@ -0,0 +1,155 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import type { StorageManager } from '../storage/index.js'; +import type { + IStoredApiToken, + IApiTokenInfo, + TApiTokenScope, +} from '../../ts_interfaces/data/route-management.js'; + +const TOKENS_PREFIX = '/config-api/tokens/'; +const TOKEN_PREFIX_STR = 'dcr_'; + +export class ApiTokenManager { + private tokens = new Map(); + + constructor(private storageManager: StorageManager) {} + + public async initialize(): Promise { + await this.loadTokens(); + if (this.tokens.size > 0) { + logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`); + } + } + + // ========================================================================= + // Token lifecycle + // ========================================================================= + + /** + * Create a new API token. Returns the raw token value (shown once). + */ + public async createToken( + name: string, + scopes: TApiTokenScope[], + expiresInDays: number | null, + createdBy: string, + ): Promise<{ id: string; rawToken: string }> { + const id = plugins.uuid.v4(); + const randomBytes = plugins.crypto.randomBytes(32); + const rawPayload = `${id}:${randomBytes.toString('base64url')}`; + const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`; + + const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); + + const now = Date.now(); + const stored: IStoredApiToken = { + id, + name, + tokenHash, + scopes, + createdAt: now, + expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null, + lastUsedAt: null, + createdBy, + enabled: true, + }; + + this.tokens.set(id, stored); + await this.persistToken(stored); + logger.log('info', `API token '${name}' created (id: ${id})`); + return { id, rawToken }; + } + + /** + * Validate a raw token string. Returns the stored token if valid, null otherwise. + * Also updates lastUsedAt. + */ + public async validateToken(rawToken: string): Promise { + if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null; + + const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); + + for (const stored of this.tokens.values()) { + if (stored.tokenHash === hash) { + if (!stored.enabled) return null; + if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null; + + // Update lastUsedAt (fire and forget) + stored.lastUsedAt = Date.now(); + this.persistToken(stored).catch(() => {}); + return stored; + } + } + return null; + } + + /** + * Check if a token has a specific scope. + */ + public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean { + return token.scopes.includes(scope); + } + + /** + * List all tokens (safe info only, no hashes). + */ + public listTokens(): IApiTokenInfo[] { + const result: IApiTokenInfo[] = []; + for (const stored of this.tokens.values()) { + result.push({ + id: stored.id, + name: stored.name, + scopes: stored.scopes, + createdAt: stored.createdAt, + expiresAt: stored.expiresAt, + lastUsedAt: stored.lastUsedAt, + enabled: stored.enabled, + }); + } + return result; + } + + /** + * Revoke (delete) a token. + */ + public async revokeToken(id: string): Promise { + if (!this.tokens.has(id)) return false; + const token = this.tokens.get(id)!; + this.tokens.delete(id); + await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`); + logger.log('info', `API token '${token.name}' revoked (id: ${id})`); + return true; + } + + /** + * Enable or disable a token. + */ + public async toggleToken(id: string, enabled: boolean): Promise { + const stored = this.tokens.get(id); + if (!stored) return false; + stored.enabled = enabled; + await this.persistToken(stored); + logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`); + return true; + } + + // ========================================================================= + // Private + // ========================================================================= + + private async loadTokens(): Promise { + const keys = await this.storageManager.list(TOKENS_PREFIX); + for (const key of keys) { + if (!key.endsWith('.json')) continue; + const stored = await this.storageManager.getJSON(key); + if (stored?.id) { + this.tokens.set(stored.id, stored); + } + } + } + + private async persistToken(stored: IStoredApiToken): Promise { + await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored); + } +} diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts new file mode 100644 index 0000000..a8d3bcd --- /dev/null +++ b/ts/config/classes.route-config-manager.ts @@ -0,0 +1,271 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import type { StorageManager } from '../storage/index.js'; +import type { + IStoredRoute, + IRouteOverride, + IMergedRoute, + IRouteWarning, +} from '../../ts_interfaces/data/route-management.js'; + +const ROUTES_PREFIX = '/config-api/routes/'; +const OVERRIDES_PREFIX = '/config-api/overrides/'; + +export class RouteConfigManager { + private storedRoutes = new Map(); + private overrides = new Map(); + private warnings: IRouteWarning[] = []; + + constructor( + private storageManager: StorageManager, + private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], + private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, + ) {} + + /** + * Load persisted routes and overrides, compute warnings, apply to SmartProxy. + */ + public async initialize(): Promise { + await this.loadStoredRoutes(); + await this.loadOverrides(); + this.computeWarnings(); + this.logWarnings(); + await this.applyRoutes(); + } + + // ========================================================================= + // Merged view + // ========================================================================= + + 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); + 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, + }); + } + + return { routes: merged, warnings: [...this.warnings] }; + } + + // ========================================================================= + // Programmatic route CRUD + // ========================================================================= + + public async createRoute( + route: plugins.smartproxy.IRouteConfig, + createdBy: string, + enabled = true, + ): Promise { + const id = plugins.uuid.v4(); + const now = Date.now(); + + // Ensure route has a name + if (!route.name) { + route.name = `programmatic-${id.slice(0, 8)}`; + } + + const stored: IStoredRoute = { + id, + route, + enabled, + createdAt: now, + updatedAt: now, + createdBy, + }; + + this.storedRoutes.set(id, stored); + await this.persistRoute(stored); + await this.applyRoutes(); + return id; + } + + public async updateRoute( + id: string, + patch: { route?: Partial; enabled?: boolean }, + ): Promise { + const stored = this.storedRoutes.get(id); + if (!stored) return false; + + if (patch.route) { + stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig; + } + if (patch.enabled !== undefined) { + stored.enabled = patch.enabled; + } + stored.updatedAt = Date.now(); + + await this.persistRoute(stored); + await this.applyRoutes(); + return true; + } + + public async deleteRoute(id: string): Promise { + if (!this.storedRoutes.has(id)) return false; + this.storedRoutes.delete(id); + await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`); + await this.applyRoutes(); + return true; + } + + public async toggleRoute(id: string, enabled: boolean): Promise { + return this.updateRoute(id, { enabled }); + } + + // ========================================================================= + // Hardcoded route overrides + // ========================================================================= + + public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise { + const override: IRouteOverride = { + routeName, + enabled, + updatedAt: Date.now(), + updatedBy, + }; + this.overrides.set(routeName, override); + await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override); + this.computeWarnings(); + await this.applyRoutes(); + } + + public async removeOverride(routeName: string): Promise { + if (!this.overrides.has(routeName)) return false; + this.overrides.delete(routeName); + await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`); + this.computeWarnings(); + await this.applyRoutes(); + return true; + } + + // ========================================================================= + // Private: persistence + // ========================================================================= + + private async loadStoredRoutes(): Promise { + const keys = await this.storageManager.list(ROUTES_PREFIX); + for (const key of keys) { + if (!key.endsWith('.json')) continue; + const stored = await this.storageManager.getJSON(key); + if (stored?.id) { + this.storedRoutes.set(stored.id, stored); + } + } + if (this.storedRoutes.size > 0) { + logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`); + } + } + + private async loadOverrides(): Promise { + const keys = await this.storageManager.list(OVERRIDES_PREFIX); + for (const key of keys) { + if (!key.endsWith('.json')) continue; + const override = await this.storageManager.getJSON(key); + if (override?.routeName) { + this.overrides.set(override.routeName, override); + } + } + if (this.overrides.size > 0) { + logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`); + } + } + + private async persistRoute(stored: IStoredRoute): Promise { + await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored); + } + + // ========================================================================= + // Private: warnings + // ========================================================================= + + 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)) { + 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', + routeName: name, + message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`, + }); + } + } + } + + private logWarnings(): void { + for (const w of this.warnings) { + logger.log('warn', w.message); + } + } + + // ========================================================================= + // Private: apply merged routes to SmartProxy + // ========================================================================= + + private async applyRoutes(): Promise { + const smartProxy = this.getSmartProxy(); + if (!smartProxy) return; + + const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; + + // Add enabled hardcoded routes (respecting overrides) + 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(route); + } + + // Add enabled programmatic routes + for (const stored of this.storedRoutes.values()) { + if (stored.enabled) { + enabledRoutes.push(stored.route); + } + } + + await smartProxy.updateRoutes(enabledRoutes); + logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`); + } +} diff --git a/ts/config/index.ts b/ts/config/index.ts index 3a10495..ecf449f 100644 --- a/ts/config/index.ts +++ b/ts/config/index.ts @@ -1,2 +1,4 @@ // Export validation tools only -export * from './validator.js'; \ No newline at end of file +export * from './validator.js'; +export { RouteConfigManager } from './classes.route-config-manager.js'; +export { ApiTokenManager } from './classes.api-token-manager.js'; \ No newline at end of file diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 3501c9c..618fafc 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -20,6 +20,8 @@ export class OpsServer { private emailOpsHandler: handlers.EmailOpsHandler; private certificateHandler: handlers.CertificateHandler; private remoteIngressHandler: handlers.RemoteIngressHandler; + private routeManagementHandler: handlers.RouteManagementHandler; + private apiTokenHandler: handlers.ApiTokenHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -61,6 +63,8 @@ export class OpsServer { this.emailOpsHandler = new handlers.EmailOpsHandler(this); this.certificateHandler = new handlers.CertificateHandler(this); this.remoteIngressHandler = new handlers.RemoteIngressHandler(this); + this.routeManagementHandler = new handlers.RouteManagementHandler(this); + this.apiTokenHandler = new handlers.ApiTokenHandler(this); console.log('✅ OpsServer TypedRequest handlers initialized'); } diff --git a/ts/opsserver/handlers/api-token.handler.ts b/ts/opsserver/handlers/api-token.handler.ts new file mode 100644 index 0000000..c4e0668 --- /dev/null +++ b/ts/opsserver/handlers/api-token.handler.ts @@ -0,0 +1,96 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +export class ApiTokenHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + /** + * Token management requires admin JWT only (tokens cannot manage tokens). + */ + private async requireAdmin(identity?: interfaces.data.IIdentity): Promise { + if (!identity?.jwt) { + throw new plugins.typedrequest.TypedResponseError('unauthorized'); + } + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ identity }); + if (!isAdmin) { + throw new plugins.typedrequest.TypedResponseError('admin access required'); + } + return identity.userId; + } + + private registerHandlers(): void { + // Create API token + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createApiToken', + async (dataArg) => { + const userId = await this.requireAdmin(dataArg.identity); + const manager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (!manager) { + return { success: false, message: 'Token management not initialized' }; + } + const result = await manager.createToken( + dataArg.name, + dataArg.scopes, + dataArg.expiresInDays ?? null, + userId, + ); + return { success: true, tokenId: result.id, tokenValue: result.rawToken }; + }, + ), + ); + + // List API tokens + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'listApiTokens', + async (dataArg) => { + await this.requireAdmin(dataArg.identity); + const manager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (!manager) { + return { tokens: [] }; + } + return { tokens: manager.listTokens() }; + }, + ), + ); + + // Revoke API token + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'revokeApiToken', + async (dataArg) => { + await this.requireAdmin(dataArg.identity); + const manager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (!manager) { + return { success: false, message: 'Token management not initialized' }; + } + const ok = await manager.revokeToken(dataArg.id); + return { success: ok, message: ok ? undefined : 'Token not found' }; + }, + ), + ); + + // Toggle API token + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'toggleApiToken', + async (dataArg) => { + await this.requireAdmin(dataArg.identity); + const manager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (!manager) { + return { success: false, message: 'Token management not initialized' }; + } + const ok = await manager.toggleToken(dataArg.id, dataArg.enabled); + return { success: ok, message: ok ? undefined : 'Token not found' }; + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index ab72bfe..e961c3b 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -6,4 +6,6 @@ export * from './stats.handler.js'; export * from './radius.handler.js'; export * from './email-ops.handler.js'; export * from './certificate.handler.js'; -export * from './remoteingress.handler.js'; \ No newline at end of file +export * from './remoteingress.handler.js'; +export * from './route-management.handler.js'; +export * from './api-token.handler.js'; \ No newline at end of file diff --git a/ts/opsserver/handlers/route-management.handler.ts b/ts/opsserver/handlers/route-management.handler.ts new file mode 100644 index 0000000..da7f8f1 --- /dev/null +++ b/ts/opsserver/handlers/route-management.handler.ts @@ -0,0 +1,163 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +export class RouteManagementHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + /** + * Validate auth: JWT identity OR API token with required scope. + * Returns a userId string on success, throws on failure. + */ + private async requireAuth( + request: { identity?: interfaces.data.IIdentity; apiToken?: string }, + requiredScope?: interfaces.data.TApiTokenScope, + ): Promise { + // Try JWT identity first + if (request.identity?.jwt) { + try { + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ + identity: request.identity, + }); + if (isAdmin) return request.identity.userId; + } catch { /* fall through */ } + } + + // Try API token + if (request.apiToken) { + const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (tokenManager) { + const token = await tokenManager.validateToken(request.apiToken); + if (token) { + if (!requiredScope || tokenManager.hasScope(token, requiredScope)) { + return token.createdBy; + } + throw new plugins.typedrequest.TypedResponseError('insufficient scope'); + } + } + } + + throw new plugins.typedrequest.TypedResponseError('unauthorized'); + } + + private registerHandlers(): void { + // Get merged routes + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getMergedRoutes', + async (dataArg) => { + await this.requireAuth(dataArg, 'routes:read'); + const manager = this.opsServerRef.dcRouterRef.routeConfigManager; + if (!manager) { + return { routes: [], warnings: [] }; + } + return manager.getMergedRoutes(); + }, + ), + ); + + // Create route + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createRoute', + 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' }; + } + const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true); + return { success: true, storedRouteId: id }; + }, + ), + ); + + // Update route + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateRoute', + 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.updateRoute(dataArg.id, { + route: dataArg.route as any, + enabled: dataArg.enabled, + }); + return { success: ok, message: ok ? undefined : 'Route not found' }; + }, + ), + ); + + // Delete route + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteRoute', + 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.deleteRoute(dataArg.id); + return { success: ok, message: ok ? undefined : 'Route not found' }; + }, + ), + ); + + // 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 + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'toggleRoute', + 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.toggleRoute(dataArg.id, dataArg.enabled); + return { success: ok, message: ok ? undefined : 'Route not found' }; + }, + ), + ); + } +} diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 200340e..79deb2a 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -1,3 +1,4 @@ export * from './auth.js'; export * from './stats.js'; -export * from './remoteingress.js'; \ No newline at end of file +export * from './remoteingress.js'; +export * from './route-management.js'; \ No newline at end of file diff --git a/ts_interfaces/data/route-management.ts b/ts_interfaces/data/route-management.ts new file mode 100644 index 0000000..f2100cc --- /dev/null +++ b/ts_interfaces/data/route-management.ts @@ -0,0 +1,83 @@ +import type { IRouteConfig } from '@push.rocks/smartproxy'; + +// ============================================================================ +// Route Management Data Types +// ============================================================================ + +export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage'; + +/** + * A merged route combining hardcoded and programmatic sources. + */ +export interface IMergedRoute { + route: IRouteConfig; + source: 'hardcoded' | 'programmatic'; + enabled: boolean; + overridden: boolean; + storedRouteId?: string; + createdAt?: number; + updatedAt?: number; +} + +/** + * A warning generated during route merge/startup. + */ +export interface IRouteWarning { + type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override'; + routeName: string; + message: string; +} + +/** + * Public info about an API token (never includes the hash). + */ +export interface IApiTokenInfo { + id: string; + name: string; + scopes: TApiTokenScope[]; + createdAt: number; + expiresAt: number | null; + lastUsedAt: number | null; + enabled: boolean; +} + +// ============================================================================ +// Storage Schemas (persisted via StorageManager) +// ============================================================================ + +/** + * A programmatic route stored in /config-api/routes/{id}.json + */ +export interface IStoredRoute { + id: string; + route: IRouteConfig; + enabled: boolean; + createdAt: number; + updatedAt: number; + createdBy: string; +} + +/** + * 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 + */ +export interface IStoredApiToken { + id: string; + name: string; + tokenHash: string; + scopes: TApiTokenScope[]; + createdAt: number; + expiresAt: number | null; + lastUsedAt: number | null; + createdBy: string; + enabled: boolean; +} diff --git a/ts_interfaces/requests/api-tokens.ts b/ts_interfaces/requests/api-tokens.ts new file mode 100644 index 0000000..3674ae4 --- /dev/null +++ b/ts_interfaces/requests/api-tokens.ts @@ -0,0 +1,83 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { IApiTokenInfo, TApiTokenScope } from '../data/route-management.js'; + +// ============================================================================ +// API Token Management Endpoints +// ============================================================================ + +/** + * Create a new API token. Returns the raw token value once (never shown again). + * Admin JWT only — tokens cannot create tokens. + */ +export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateApiToken +> { + method: 'createApiToken'; + request: { + identity?: authInterfaces.IIdentity; + name: string; + scopes: TApiTokenScope[]; + expiresInDays?: number | null; + }; + response: { + success: boolean; + tokenId?: string; + tokenValue?: string; + message?: string; + }; +} + +/** + * List all API tokens (without hashes). + */ +export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ListApiTokens +> { + method: 'listApiTokens'; + request: { + identity?: authInterfaces.IIdentity; + }; + response: { + tokens: IApiTokenInfo[]; + }; +} + +/** + * Revoke (delete) an API token. + */ +export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RevokeApiToken +> { + method: 'revokeApiToken'; + request: { + identity?: authInterfaces.IIdentity; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Enable or disable an API token. + */ +export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ToggleApiToken +> { + method: 'toggleApiToken'; + request: { + identity?: authInterfaces.IIdentity; + id: string; + enabled: boolean; + }; + response: { + success: boolean; + message?: string; + }; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index c5c4975..83a0cba 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -6,4 +6,6 @@ export * from './combined.stats.js'; export * from './radius.js'; export * from './email-ops.js'; export * from './certificate.js'; -export * from './remoteingress.js'; \ No newline at end of file +export * from './remoteingress.js'; +export * from './route-management.js'; +export * from './api-tokens.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/route-management.ts b/ts_interfaces/requests/route-management.ts new file mode 100644 index 0000000..55c3bc9 --- /dev/null +++ b/ts_interfaces/requests/route-management.ts @@ -0,0 +1,146 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { IMergedRoute, IRouteWarning } from '../data/route-management.js'; +import type { IRouteConfig } from '@push.rocks/smartproxy'; + +// ============================================================================ +// Route Management Endpoints +// ============================================================================ + +/** + * Get all merged routes (hardcoded + programmatic) with warnings. + */ +export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetMergedRoutes +> { + method: 'getMergedRoutes'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + routes: IMergedRoute[]; + warnings: IRouteWarning[]; + }; +} + +/** + * Create a new programmatic route. + */ +export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateRoute +> { + method: 'createRoute'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + route: IRouteConfig; + enabled?: boolean; + }; + response: { + success: boolean; + storedRouteId?: string; + message?: string; + }; +} + +/** + * Update a programmatic route. + */ +export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateRoute +> { + method: 'updateRoute'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + route?: Partial; + enabled?: boolean; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Delete a programmatic route. + */ +export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteRoute +> { + method: 'deleteRoute'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * 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. + */ +export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ToggleRoute +> { + method: 'toggleRoute'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + enabled: boolean; + }; + response: { + success: boolean; + message?: string; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 32d755a..869f310 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: '8.0.0', + version: '8.1.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 06e8c33..c7606d6 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -109,7 +109,7 @@ export const configStatePart = await appState.getStatePart( // Determine initial view from URL path const getInitialView = (): string => { const path = typeof window !== 'undefined' ? window.location.pathname : '/'; - const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress']; + const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress']; const segments = path.split('/').filter(Boolean); const view = segments[0]; return validViews.includes(view) ? view : 'overview'; @@ -206,6 +206,32 @@ export const remoteIngressStatePart = await appState.getStatePart( + 'routeManagement', + { + mergedRoutes: [], + warnings: [], + apiTokens: [], + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft' +); + // Actions for state management interface IActionContext { identity: interfaces.data.IIdentity | null; @@ -392,6 +418,20 @@ export const setActiveViewAction = uiStatePart.createAction(async (state }, 100); } + // If switching to routes view, ensure we fetch route data + if (viewName === 'routes' && currentState.activeView !== 'routes') { + setTimeout(() => { + routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); + }, 100); + } + + // If switching to apitokens view, ensure we fetch token data + if (viewName === 'apitokens' && currentState.activeView !== 'apitokens') { + setTimeout(() => { + routeManagementStatePart.dispatchAction(fetchApiTokensAction, null); + }, 100); + } + // If switching to remoteingress view, ensure we fetch edge data if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') { setTimeout(() => { @@ -862,6 +902,273 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{ } }); +// ============================================================================ +// Route Management Actions +// ============================================================================ + +export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetMergedRoutes + >('/typedrequest', 'getMergedRoutes'); + + const response = await request.fire({ + identity: context.identity, + }); + + return { + ...currentState, + mergedRoutes: response.routes, + warnings: response.warnings, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch routes', + }; + } +}); + +export const createRouteAction = routeManagementStatePart.createAction<{ + route: any; + enabled?: boolean; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateRoute + >('/typedrequest', 'createRoute'); + + await request.fire({ + identity: context.identity, + route: dataArg.route, + enabled: dataArg.enabled, + }); + + await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to create route', + }; + } +}); + +export const deleteRouteAction = routeManagementStatePart.createAction( + async (statePartArg, routeId) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteRoute + >('/typedrequest', 'deleteRoute'); + + await request.fire({ + identity: context.identity, + id: routeId, + }); + + await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to delete route', + }; + } + } +); + +export const toggleRouteAction = routeManagementStatePart.createAction<{ + id: string; + enabled: boolean; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ToggleRoute + >('/typedrequest', 'toggleRoute'); + + await request.fire({ + identity: context.identity, + id: dataArg.id, + enabled: dataArg.enabled, + }); + + await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to toggle route', + }; + } +}); + +export const setRouteOverrideAction = routeManagementStatePart.createAction<{ + routeName: string; + enabled: boolean; +}>(async (statePartArg, dataArg) => { + 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, + }); + + await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to set override', + }; + } +}); + +export const removeRouteOverrideAction = routeManagementStatePart.createAction( + async (statePartArg, routeName) => { + 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, + }); + + await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to remove override', + }; + } + } +); + +// ============================================================================ +// API Token Actions +// ============================================================================ + +export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ListApiTokens + >('/typedrequest', 'listApiTokens'); + + const response = await request.fire({ + identity: context.identity, + }); + + return { + ...currentState, + apiTokens: response.tokens, + }; + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to fetch tokens', + }; + } +}); + +export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) { + const context = getActionContext(); + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateApiToken + >('/typedrequest', 'createApiToken'); + + return request.fire({ + identity: context.identity, + name, + scopes, + expiresInDays, + }); +} + +export const revokeApiTokenAction = routeManagementStatePart.createAction( + async (statePartArg, tokenId) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_RevokeApiToken + >('/typedrequest', 'revokeApiToken'); + + await request.fire({ + identity: context.identity, + id: tokenId, + }); + + await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to revoke token', + }; + } + } +); + +export const toggleApiTokenAction = routeManagementStatePart.createAction<{ + id: string; + enabled: boolean; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ToggleApiToken + >('/typedrequest', 'toggleApiToken'); + + await request.fire({ + identity: context.identity, + id: dataArg.id, + enabled: dataArg.enabled, + }); + + await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to toggle token', + }; + } +}); + // ============================================================================ // TypedSocket Client for Real-time Log Streaming // ============================================================================ diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index adaf409..67f499d 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -4,6 +4,8 @@ export * from './ops-view-network.js'; export * from './ops-view-emails.js'; export * from './ops-view-logs.js'; export * from './ops-view-config.js'; +export * from './ops-view-routes.js'; +export * from './ops-view-apitokens.js'; export * from './ops-view-security.js'; export * from './ops-view-certificates.js'; export * from './ops-view-remoteingress.js'; diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 9217310..e1d85f0 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -18,6 +18,8 @@ import { OpsViewNetwork } from './ops-view-network.js'; import { OpsViewEmails } from './ops-view-emails.js'; import { OpsViewLogs } from './ops-view-logs.js'; import { OpsViewConfig } from './ops-view-config.js'; +import { OpsViewRoutes } from './ops-view-routes.js'; +import { OpsViewApiTokens } from './ops-view-apitokens.js'; import { OpsViewSecurity } from './ops-view-security.js'; import { OpsViewCertificates } from './ops-view-certificates.js'; import { OpsViewRemoteIngress } from './ops-view-remoteingress.js'; @@ -55,6 +57,14 @@ export class OpsDashboard extends DeesElement { name: 'Logs', element: OpsViewLogs, }, + { + name: 'Routes', + element: OpsViewRoutes, + }, + { + name: 'ApiTokens', + element: OpsViewApiTokens, + }, { name: 'Configuration', element: OpsViewConfig, diff --git a/ts_web/elements/ops-view-apitokens.ts b/ts_web/elements/ops-view-apitokens.ts new file mode 100644 index 0000000..2cc1974 --- /dev/null +++ b/ts_web/elements/ops-view-apitokens.ts @@ -0,0 +1,281 @@ +import * as appstate from '../appstate.js'; +import * as interfaces from '../../dist_ts_interfaces/index.js'; +import { viewHostCss } from './shared/css.js'; + +import { + DeesElement, + css, + cssManager, + customElement, + html, + state, + type TemplateResult, +} from '@design.estate/dees-element'; + +type TApiTokenScope = interfaces.data.TApiTokenScope; + +@customElement('ops-view-apitokens') +export class OpsViewApiTokens extends DeesElement { + @state() accessor routeState: appstate.IRouteManagementState = { + mergedRoutes: [], + warnings: [], + apiTokens: [], + isLoading: false, + error: null, + lastUpdated: 0, + }; + + constructor() { + super(); + const sub = appstate.routeManagementStatePart + .select((s) => s) + .subscribe((routeState) => { + this.routeState = routeState; + }); + this.rxSubscriptions.push(sub); + + // Re-fetch tokens when user logs in (fixes race condition where + // the view is created before authentication completes) + const loginSub = appstate.loginStatePart + .select((s) => s.isLoggedIn) + .subscribe((isLoggedIn) => { + if (isLoggedIn) { + appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); + } + }); + this.rxSubscriptions.push(loginSub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .apiTokensContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .scopePill { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')}; + color: ${cssManager.bdTheme('#0369a1', '#0af')}; + margin-right: 4px; + margin-bottom: 2px; + } + + .statusBadge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .statusBadge.active { + background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; + color: ${cssManager.bdTheme('#166534', '#4ade80')}; + } + + .statusBadge.disabled { + background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; + color: ${cssManager.bdTheme('#991b1b', '#f87171')}; + } + + .statusBadge.expired { + background: ${cssManager.bdTheme('#f3f4f6', '#374151')}; + color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; + } + `, + ]; + + public render(): TemplateResult { + const { apiTokens } = this.routeState; + + return html` + API Tokens + +
+ ({ + name: token.name, + scopes: this.renderScopePills(token.scopes), + status: this.renderStatusBadge(token), + created: new Date(token.createdAt).toLocaleDateString(), + expires: token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : 'Never', + lastUsed: token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : 'Never', + })} + .dataActions=${[ + { + name: 'Create Token', + iconName: 'lucide:plus', + type: ['header'], + actionFunc: async () => { + await this.showCreateTokenDialog(); + }, + }, + { + name: 'Enable', + iconName: 'lucide:play', + type: ['inRow', 'contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled, + actionFunc: async (actionData: any) => { + const token = actionData.item as interfaces.data.IApiTokenInfo; + await appstate.routeManagementStatePart.dispatchAction( + appstate.toggleApiTokenAction, + { id: token.id, enabled: true }, + ); + }, + }, + { + name: 'Disable', + iconName: 'lucide:pause', + type: ['inRow', 'contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled, + actionFunc: async (actionData: any) => { + const token = actionData.item as interfaces.data.IApiTokenInfo; + await appstate.routeManagementStatePart.dispatchAction( + appstate.toggleApiTokenAction, + { id: token.id, enabled: false }, + ); + }, + }, + { + name: 'Revoke', + iconName: 'lucide:trash2', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const token = actionData.item as interfaces.data.IApiTokenInfo; + await appstate.routeManagementStatePart.dispatchAction( + appstate.revokeApiTokenAction, + token.id, + ); + }, + }, + ]} + > +
+ `; + } + + private renderScopePills(scopes: TApiTokenScope[]): TemplateResult { + return html`
${scopes.map( + (s) => html`${s}`, + )}
`; + } + + private renderStatusBadge(token: interfaces.data.IApiTokenInfo): TemplateResult { + if (!token.enabled) { + return html`Disabled`; + } + if (token.expiresAt && token.expiresAt < Date.now()) { + return html`Expired`; + } + return html`Active`; + } + + private async showCreateTokenDialog() { + const { DeesModal } = await import('@design.estate/dees-catalog'); + + const allScopes: TApiTokenScope[] = [ + 'routes:read', + 'routes:write', + 'config:read', + 'tokens:read', + 'tokens:manage', + ]; + + await DeesModal.createAndShow({ + heading: 'Create API Token', + content: html` +
+ The token value will be shown once after creation. Copy it immediately. +
+ + + + + + `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Create', + iconName: 'lucide:key', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const formData = await form.collectFormData(); + if (!formData.name) return; + + // dees-input-tags returns string[] directly + const scopes = (formData.scopes || []) + .filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[]; + + const expiresInDays = formData.expiresInDays + ? parseInt(formData.expiresInDays, 10) + : null; + + await modalArg.destroy(); + + try { + const response = await appstate.createApiToken(formData.name, scopes, expiresInDays); + if (response.success && response.tokenValue) { + // Refresh the list first so it's ready when user dismisses the modal + await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); + + // Show the token value in a new modal + await DeesModal.createAndShow({ + heading: 'Token Created', + content: html` +
+

Copy this token now. It will not be shown again.

+
+ ${response.tokenValue} +
+
+ `, + menuOptions: [ + { + name: 'Done', + iconName: 'lucide:check', + action: async (m: any) => await m.destroy(), + }, + ], + }); + } + } catch (error) { + console.error('Failed to create token:', error); + } + }, + }, + ], + }); + } + + async firstUpdated() { + await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); + } +} diff --git a/ts_web/elements/ops-view-routes.ts b/ts_web/elements/ops-view-routes.ts new file mode 100644 index 0000000..6e3513f --- /dev/null +++ b/ts_web/elements/ops-view-routes.ts @@ -0,0 +1,389 @@ +import * as appstate from '../appstate.js'; +import * as interfaces from '../../dist_ts_interfaces/index.js'; +import { viewHostCss } from './shared/css.js'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +import { + DeesElement, + css, + cssManager, + customElement, + html, + state, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('ops-view-routes') +export class OpsViewRoutes extends DeesElement { + @state() accessor routeState: appstate.IRouteManagementState = { + mergedRoutes: [], + warnings: [], + apiTokens: [], + isLoading: false, + error: null, + lastUpdated: 0, + }; + + constructor() { + super(); + const sub = appstate.routeManagementStatePart + .select((s) => s) + .subscribe((routeState) => { + this.routeState = routeState; + }); + this.rxSubscriptions.push(sub); + + // Re-fetch routes when user logs in (fixes race condition where + // the view is created before authentication completes) + const loginSub = appstate.loginStatePart + .select((s) => s.isLoggedIn) + .subscribe((isLoggedIn) => { + if (isLoggedIn) { + appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); + } + }); + this.rxSubscriptions.push(loginSub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .routesContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .warnings-bar { + background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')}; + border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')}; + border-radius: 8px; + padding: 12px 16px; + } + + .warning-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 13px; + color: ${cssManager.bdTheme('#b45309', '#fa0')}; + } + + .warning-icon { + flex-shrink: 0; + } + + .empty-state { + text-align: center; + padding: 48px 24px; + color: ${cssManager.bdTheme('#6b7280', '#666')}; + } + + .empty-state p { + margin: 8px 0; + } + `, + ]; + + 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 statsTiles: IStatsTile[] = [ + { + id: 'totalRoutes', + title: 'Total Routes', + type: 'number', + value: mergedRoutes.length, + icon: 'lucide:route', + description: 'All configured routes', + color: '#3b82f6', + }, + { + id: 'hardcoded', + title: 'Hardcoded', + type: 'number', + value: hardcodedCount, + icon: 'lucide:lock', + description: 'Routes from constructor config', + color: '#8b5cf6', + }, + { + id: 'programmatic', + title: 'Programmatic', + type: 'number', + value: programmaticCount, + icon: 'lucide:code', + description: 'Routes added via API', + color: '#0ea5e9', + }, + { + id: 'disabled', + title: 'Disabled', + type: 'number', + value: disabledCount, + icon: 'lucide:pauseCircle', + description: 'Currently disabled routes', + color: disabledCount > 0 ? '#ef4444' : '#6b7280', + }, + ]; + + // Map merged routes to sz-route-list-view format + const szRoutes = mergedRoutes.map((mr) => { + const tags = [...(mr.route.tags || [])]; + tags.push(mr.source); + 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, + }; + }); + + return html` + Route Management + +
+ this.showCreateRouteDialog(), + }, + { + name: 'Refresh', + iconName: 'lucide:refreshCw', + action: () => this.refreshData(), + }, + ]} + > + + ${warnings.length > 0 + ? html` +
+ ${warnings.map( + (w) => html` +
+ + ${w.message} +
+ `, + )} +
+ ` + : ''} + + ${szRoutes.length > 0 + ? html` + this.handleRouteClick(e)} + > + ` + : html` +
+

No routes configured

+

Add a programmatic route or check your constructor configuration.

+
+ `} +
+ `; + } + + private async handleRouteClick(e: CustomEvent) { + const clickedRoute = e.detail; + if (!clickedRoute) return; + + // Find the corresponding merged route + const merged = this.routeState.mergedRoutes.find( + (mr) => mr.route.name === clickedRoute.name, + ); + if (!merged) return; + + 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 + await DeesModal.createAndShow({ + heading: `Route: ${merged.route.name}`, + content: html` +
+

Source: programmatic

+

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

+

ID: ${merged.storedRouteId}

+
+ `, + 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(); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash-2', + action: async (modalArg: any) => { + await appstate.routeManagementStatePart.dispatchAction( + appstate.deleteRouteAction, + merged.storedRouteId!, + ); + await modalArg.destroy(); + }, + }, + { + name: 'Close', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + ], + }); + } + } + + private async showCreateRouteDialog() { + const { DeesModal } = await import('@design.estate/dees-catalog'); + + await DeesModal.createAndShow({ + heading: 'Add Programmatic Route', + content: html` + + + + + + + + `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Create', + iconName: 'lucide:plus', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const formData = await form.collectFormData(); + if (!formData.name || !formData.ports) return; + + const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p)); + const domains = formData.domains + ? formData.domains.split(',').map((d: string) => d.trim()).filter(Boolean) + : undefined; + + const route: any = { + name: formData.name, + match: { + ports, + ...(domains && domains.length > 0 ? { domains } : {}), + }, + action: { + type: 'forward', + targets: [ + { + host: formData.targetHost || 'localhost', + port: parseInt(formData.targetPort, 10), + }, + ], + }, + }; + + await appstate.routeManagementStatePart.dispatchAction( + appstate.createRouteAction, + { route }, + ); + await modalArg.destroy(); + }, + }, + ], + }); + } + + private refreshData() { + appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); + } + + async firstUpdated() { + await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null); + } +} diff --git a/ts_web/router.ts b/ts_web/router.ts index d1e9316..4195850 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -3,7 +3,7 @@ import * as appstate from './appstate.js'; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; -export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'] as const; +export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const; export type TValidView = typeof validViews[number];