From f5028ffb60ae5cd51df9b8a0baec119b82382bac Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 23 Feb 2026 12:40:26 +0000 Subject: [PATCH] feat(route-management): add programmatic route management API with API tokens and admin UI --- .../console-2026-02-23T10-44-24-024Z.log | 7 + .../console-2026-02-23T11-19-21-255Z.log | 12 + .../console-2026-02-23T11-20-31-682Z.log | 6 + .../console-2026-02-23T11-21-09-382Z.log | 50 +++ .../console-2026-02-23T11-23-44-606Z.log | 23 ++ .../page-2026-02-23T11-25-39-255Z.png | Bin 0 -> 45341 bytes .../page-2026-02-23T11-26-10-952Z.png | Bin 0 -> 39282 bytes .../page-2026-02-23T11-26-15-885Z.png | Bin 0 -> 39282 bytes changelog.md | 11 + package.json | 2 +- pnpm-lock.yaml | 10 +- test_watch/devserver.ts | 41 +- ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 38 +- ts/config/classes.api-token-manager.ts | 155 +++++++ ts/config/classes.route-config-manager.ts | 271 ++++++++++++ ts/config/index.ts | 4 +- ts/opsserver/classes.opsserver.ts | 4 + ts/opsserver/handlers/api-token.handler.ts | 96 +++++ ts/opsserver/handlers/index.ts | 4 +- .../handlers/route-management.handler.ts | 163 ++++++++ ts_interfaces/data/index.ts | 3 +- ts_interfaces/data/route-management.ts | 83 ++++ ts_interfaces/requests/api-tokens.ts | 83 ++++ ts_interfaces/requests/index.ts | 4 +- ts_interfaces/requests/route-management.ts | 146 +++++++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 309 +++++++++++++- ts_web/elements/index.ts | 2 + ts_web/elements/ops-dashboard.ts | 10 + ts_web/elements/ops-view-apitokens.ts | 281 +++++++++++++ ts_web/elements/ops-view-routes.ts | 389 ++++++++++++++++++ ts_web/router.ts | 2 +- 33 files changed, 2183 insertions(+), 30 deletions(-) create mode 100644 .playwright-mcp/console-2026-02-23T10-44-24-024Z.log create mode 100644 .playwright-mcp/console-2026-02-23T11-19-21-255Z.log create mode 100644 .playwright-mcp/console-2026-02-23T11-20-31-682Z.log create mode 100644 .playwright-mcp/console-2026-02-23T11-21-09-382Z.log create mode 100644 .playwright-mcp/console-2026-02-23T11-23-44-606Z.log create mode 100644 .playwright-mcp/page-2026-02-23T11-25-39-255Z.png create mode 100644 .playwright-mcp/page-2026-02-23T11-26-10-952Z.png create mode 100644 .playwright-mcp/page-2026-02-23T11-26-15-885Z.png create mode 100644 ts/config/classes.api-token-manager.ts create mode 100644 ts/config/classes.route-config-manager.ts create mode 100644 ts/opsserver/handlers/api-token.handler.ts create mode 100644 ts/opsserver/handlers/route-management.handler.ts create mode 100644 ts_interfaces/data/route-management.ts create mode 100644 ts_interfaces/requests/api-tokens.ts create mode 100644 ts_interfaces/requests/route-management.ts create mode 100644 ts_web/elements/ops-view-apitokens.ts create mode 100644 ts_web/elements/ops-view-routes.ts 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 0000000000000000000000000000000000000000..89b0ea5b7c729a274234f11cbf436f0269d01164 GIT binary patch literal 45341 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 WW!-o>3cwBs2w>96&@w5*fd2x}K>^kP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ba9b23b34e7db6cf59c535d4c121ff58abb0afc4 GIT binary patch literal 39282 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ba9b23b34e7db6cf59c535d4c121ff58abb0afc4 GIT binary patch literal 39282 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 literal 0 HcmV?d00001 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];