@serve.zone/dcrouter 13.25.0 → 13.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.smartconfig.json +3 -11
  2. package/dist_serve/bundle.js +4046 -3552
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +2 -1
  5. package/dist_ts/classes.dcrouter.js +6 -4
  6. package/dist_ts/config/classes.api-token-manager.d.ts +4 -2
  7. package/dist_ts/config/classes.api-token-manager.js +68 -6
  8. package/dist_ts/config/classes.route-config-manager.d.ts +1 -0
  9. package/dist_ts/config/classes.route-config-manager.js +38 -1
  10. package/dist_ts/db/documents/classes.api-token.doc.d.ts +2 -1
  11. package/dist_ts/db/documents/classes.api-token.doc.js +8 -2
  12. package/dist_ts/email/classes.email-domain.manager.d.ts +4 -1
  13. package/dist_ts/email/classes.email-domain.manager.js +30 -1
  14. package/dist_ts/email/classes.workapp-mail-manager.d.ts +35 -0
  15. package/dist_ts/email/classes.workapp-mail-manager.js +273 -0
  16. package/dist_ts/email/index.d.ts +1 -0
  17. package/dist_ts/email/index.js +2 -1
  18. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  19. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  20. package/dist_ts/opsserver/handlers/admin.handler.js +9 -4
  21. package/dist_ts/opsserver/handlers/api-token.handler.js +2 -2
  22. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +4 -0
  23. package/dist_ts/opsserver/handlers/certificate.handler.js +41 -11
  24. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  25. package/dist_ts/opsserver/handlers/index.js +2 -1
  26. package/dist_ts/opsserver/handlers/workhoster.handler.d.ts +26 -0
  27. package/dist_ts/opsserver/handlers/workhoster.handler.js +402 -0
  28. package/dist_ts_apiclient/classes.dcrouterapiclient.d.ts +2 -0
  29. package/dist_ts_apiclient/classes.dcrouterapiclient.js +4 -1
  30. package/dist_ts_apiclient/classes.workhoster.d.ts +14 -0
  31. package/dist_ts_apiclient/classes.workhoster.js +29 -0
  32. package/dist_ts_apiclient/index.d.ts +1 -0
  33. package/dist_ts_apiclient/index.js +2 -1
  34. package/dist_ts_interfaces/data/index.d.ts +1 -0
  35. package/dist_ts_interfaces/data/index.js +2 -1
  36. package/dist_ts_interfaces/data/route-management.d.ts +38 -1
  37. package/dist_ts_interfaces/data/workhoster.d.ts +131 -0
  38. package/dist_ts_interfaces/data/workhoster.js +2 -0
  39. package/dist_ts_interfaces/requests/api-tokens.d.ts +2 -1
  40. package/dist_ts_interfaces/requests/certificate.d.ts +12 -6
  41. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  42. package/dist_ts_interfaces/requests/index.js +2 -1
  43. package/dist_ts_interfaces/requests/workhoster.d.ts +98 -0
  44. package/dist_ts_interfaces/requests/workhoster.js +2 -0
  45. package/dist_ts_migrations/index.js +8 -2
  46. package/dist_ts_web/00_commitinfo_data.js +1 -1
  47. package/dist_ts_web/appstate.d.ts +1 -1
  48. package/dist_ts_web/appstate.js +3 -2
  49. package/dist_ts_web/elements/access/ops-view-apitokens.d.ts +1 -0
  50. package/dist_ts_web/elements/access/ops-view-apitokens.js +58 -3
  51. package/package.json +24 -23
  52. package/readme.md +108 -128
  53. package/ts/00_commitinfo_data.ts +1 -1
  54. package/ts/classes.dcrouter.ts +5 -3
  55. package/ts/config/classes.api-token-manager.ts +73 -4
  56. package/ts/config/classes.route-config-manager.ts +37 -0
  57. package/ts/db/documents/classes.api-token.doc.ts +4 -1
  58. package/ts/email/classes.email-domain.manager.ts +33 -1
  59. package/ts/email/classes.workapp-mail-manager.ts +343 -0
  60. package/ts/email/index.ts +1 -0
  61. package/ts/opsserver/classes.opsserver.ts +3 -1
  62. package/ts/opsserver/handlers/admin.handler.ts +11 -4
  63. package/ts/opsserver/handlers/api-token.handler.ts +1 -0
  64. package/ts/opsserver/handlers/certificate.handler.ts +45 -12
  65. package/ts/opsserver/handlers/index.ts +2 -1
  66. package/ts/opsserver/handlers/workhoster.handler.ts +490 -0
  67. package/ts/readme.md +2 -2
  68. package/ts_apiclient/classes.dcrouterapiclient.ts +3 -0
  69. package/ts_apiclient/classes.workhoster.ts +49 -0
  70. package/ts_apiclient/index.ts +1 -0
  71. package/ts_apiclient/readme.md +54 -44
  72. package/ts_web/00_commitinfo_data.ts +1 -1
  73. package/ts_web/appstate.ts +7 -1
  74. package/ts_web/elements/access/ops-view-apitokens.ts +58 -3
  75. package/ts_web/readme.md +36 -19
@@ -38,6 +38,7 @@ export class OpsServer {
38
38
  private dnsRecordHandler!: handlers.DnsRecordHandler;
39
39
  private acmeConfigHandler!: handlers.AcmeConfigHandler;
40
40
  private emailDomainHandler!: handlers.EmailDomainHandler;
41
+ private workHosterHandler!: handlers.WorkHosterHandler;
41
42
 
42
43
  constructor(dcRouterRefArg: DcRouter) {
43
44
  this.dcRouterRef = dcRouterRefArg;
@@ -106,6 +107,7 @@ export class OpsServer {
106
107
  this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
107
108
  this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
108
109
  this.emailDomainHandler = new handlers.EmailDomainHandler(this);
110
+ this.workHosterHandler = new handlers.WorkHosterHandler(this);
109
111
 
110
112
  console.log('✅ OpsServer TypedRequest handlers initialized');
111
113
  }
@@ -119,4 +121,4 @@ export class OpsServer {
119
121
  await this.server.stop();
120
122
  }
121
123
  }
122
- }
124
+ }
@@ -43,14 +43,21 @@ export class AdminHandler {
43
43
  }
44
44
 
45
45
  private initializeDefaultUsers(): void {
46
- // Add default admin user
46
+ const username = process.env.DCROUTER_ADMIN_USERNAME || 'admin';
47
+ const configuredPassword = process.env.DCROUTER_ADMIN_PASSWORD;
48
+ const password = configuredPassword || plugins.crypto.randomBytes(24).toString('base64url');
49
+
47
50
  const adminId = plugins.uuid.v4();
48
51
  this.users.set(adminId, {
49
52
  id: adminId,
50
- username: 'admin',
51
- password: 'admin',
53
+ username,
54
+ password,
52
55
  role: 'admin',
53
56
  });
57
+
58
+ if (!configuredPassword) {
59
+ console.warn(`DCRouter generated one-time admin password for ${username}: ${password}`);
60
+ }
54
61
  }
55
62
 
56
63
  /**
@@ -249,4 +256,4 @@ export class AdminHandler {
249
256
  name: 'adminIdentityGuard',
250
257
  }
251
258
  );
252
- }
259
+ }
@@ -26,6 +26,7 @@ export class ApiTokenHandler {
26
26
  dataArg.scopes,
27
27
  dataArg.expiresInDays ?? null,
28
28
  dataArg.identity.userId,
29
+ dataArg.policy,
29
30
  );
30
31
  return { success: true, tokenId: result.id, tokenValue: result.rawToken };
31
32
  },
@@ -26,21 +26,51 @@ export function deriveCertDomainName(domain: string): string | undefined {
26
26
  }
27
27
 
28
28
  export class CertificateHandler {
29
+ public typedrouter = new plugins.typedrequest.TypedRouter();
30
+
29
31
  constructor(private opsServerRef: OpsServer) {
32
+ this.opsServerRef.typedrouter?.addTypedRouter(this.typedrouter);
30
33
  this.registerHandlers();
31
34
  }
32
35
 
33
- private registerHandlers(): void {
34
- const viewRouter = this.opsServerRef.viewRouter;
35
- const adminRouter = this.opsServerRef.adminRouter;
36
+ private async requireAuth(
37
+ request: { identity?: interfaces.data.IIdentity; apiToken?: string },
38
+ requiredScope?: interfaces.data.TApiTokenScope,
39
+ ): Promise<string> {
40
+ if (request.identity?.jwt) {
41
+ try {
42
+ const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
43
+ identity: request.identity,
44
+ });
45
+ if (isAdmin) return request.identity.userId;
46
+ } catch { /* fall through */ }
47
+ }
48
+
49
+ if (request.apiToken) {
50
+ const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
51
+ if (tokenManager) {
52
+ const token = await tokenManager.validateToken(request.apiToken);
53
+ if (token) {
54
+ if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
55
+ return token.createdBy;
56
+ }
57
+ throw new plugins.typedrequest.TypedResponseError('insufficient scope');
58
+ }
59
+ }
60
+ }
36
61
 
37
- // ---- Read endpoints (viewRouter — valid identity required via middleware) ----
62
+ throw new plugins.typedrequest.TypedResponseError('unauthorized');
63
+ }
64
+
65
+ private registerHandlers(): void {
66
+ const router = this.typedrouter;
38
67
 
39
68
  // Get Certificate Overview
40
- viewRouter.addTypedHandler(
69
+ router.addTypedHandler(
41
70
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
42
71
  'getCertificateOverview',
43
72
  async (dataArg) => {
73
+ await this.requireAuth(dataArg, 'certificates:read');
44
74
  const certificates = await this.buildCertificateOverview();
45
75
  const summary = this.buildSummary(certificates);
46
76
  return { certificates, summary };
@@ -48,53 +78,56 @@ export class CertificateHandler {
48
78
  )
49
79
  );
50
80
 
51
- // ---- Write endpoints (adminRouter — admin identity required via middleware) ----
52
-
53
81
  // Legacy route-based reprovision (backward compat)
54
- adminRouter.addTypedHandler(
82
+ router.addTypedHandler(
55
83
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
56
84
  'reprovisionCertificate',
57
85
  async (dataArg) => {
86
+ await this.requireAuth(dataArg, 'certificates:write');
58
87
  return this.reprovisionCertificateByRoute(dataArg.routeName);
59
88
  }
60
89
  )
61
90
  );
62
91
 
63
92
  // Domain-based reprovision (preferred)
64
- adminRouter.addTypedHandler(
93
+ router.addTypedHandler(
65
94
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
66
95
  'reprovisionCertificateDomain',
67
96
  async (dataArg) => {
97
+ await this.requireAuth(dataArg, 'certificates:write');
68
98
  return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
69
99
  }
70
100
  )
71
101
  );
72
102
 
73
103
  // Delete certificate
74
- adminRouter.addTypedHandler(
104
+ router.addTypedHandler(
75
105
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
76
106
  'deleteCertificate',
77
107
  async (dataArg) => {
108
+ await this.requireAuth(dataArg, 'certificates:write');
78
109
  return this.deleteCertificate(dataArg.domain);
79
110
  }
80
111
  )
81
112
  );
82
113
 
83
114
  // Export certificate
84
- adminRouter.addTypedHandler(
115
+ router.addTypedHandler(
85
116
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
86
117
  'exportCertificate',
87
118
  async (dataArg) => {
119
+ await this.requireAuth(dataArg, 'certificates:read');
88
120
  return this.exportCertificate(dataArg.domain);
89
121
  }
90
122
  )
91
123
  );
92
124
 
93
125
  // Import certificate
94
- adminRouter.addTypedHandler(
126
+ router.addTypedHandler(
95
127
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
96
128
  'importCertificate',
97
129
  async (dataArg) => {
130
+ await this.requireAuth(dataArg, 'certificates:write');
98
131
  return this.importCertificate(dataArg.cert);
99
132
  }
100
133
  )
@@ -18,4 +18,5 @@ export * from './dns-provider.handler.js';
18
18
  export * from './domain.handler.js';
19
19
  export * from './dns-record.handler.js';
20
20
  export * from './acme-config.handler.js';
21
- export * from './email-domain.handler.js';
21
+ export * from './email-domain.handler.js';
22
+ export * from './workhoster.handler.js';
@@ -0,0 +1,490 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import type { OpsServer } from '../classes.opsserver.js';
3
+ import * as interfaces from '../../../ts_interfaces/index.js';
4
+
5
+ type TAuthContext = {
6
+ userId: string;
7
+ isAdmin: boolean;
8
+ token?: interfaces.data.IStoredApiToken;
9
+ };
10
+
11
+ export class WorkHosterHandler {
12
+ public typedrouter = new plugins.typedrequest.TypedRouter();
13
+
14
+ constructor(private opsServerRef: OpsServer) {
15
+ this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
16
+ this.registerHandlers();
17
+ }
18
+
19
+ private async requireAuth(
20
+ request: { identity?: interfaces.data.IIdentity; apiToken?: string },
21
+ requiredScope?: interfaces.data.TApiTokenScope,
22
+ ): Promise<TAuthContext> {
23
+ if (request.identity?.jwt) {
24
+ try {
25
+ const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
26
+ identity: request.identity,
27
+ });
28
+ if (isAdmin) return { userId: request.identity.userId, isAdmin: true };
29
+ } catch { /* fall through */ }
30
+ }
31
+
32
+ if (request.apiToken) {
33
+ const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
34
+ if (tokenManager) {
35
+ const token = await tokenManager.validateToken(request.apiToken);
36
+ if (token) {
37
+ if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
38
+ return { userId: token.createdBy, isAdmin: token.policy?.role === 'admin', token };
39
+ }
40
+ throw new plugins.typedrequest.TypedResponseError('insufficient scope');
41
+ }
42
+ }
43
+ }
44
+
45
+ throw new plugins.typedrequest.TypedResponseError('unauthorized');
46
+ }
47
+
48
+ private registerHandlers(): void {
49
+ this.typedrouter.addTypedHandler(
50
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
51
+ 'getGatewayCapabilities',
52
+ async (dataArg) => {
53
+ await this.requireAuth(dataArg, 'gateway-clients:read');
54
+ return { capabilities: this.getGatewayCapabilities() };
55
+ },
56
+ ),
57
+ );
58
+
59
+ this.typedrouter.addTypedHandler(
60
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDomains>(
61
+ 'getGatewayClientDomains',
62
+ async (dataArg) => {
63
+ const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
64
+ this.assertCapability(auth, 'readDomains');
65
+ return { domains: await this.listGatewayClientDomains(auth, dataArg.gatewayClientId) };
66
+ },
67
+ ),
68
+ );
69
+
70
+ this.typedrouter.addTypedHandler(
71
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDnsRecords>(
72
+ 'getGatewayClientDnsRecords',
73
+ async (dataArg) => {
74
+ const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
75
+ this.assertCapability(auth, 'readDnsRecords');
76
+ return { records: await this.listGatewayClientDnsRecords(auth, dataArg.gatewayClientId) };
77
+ },
78
+ ),
79
+ );
80
+
81
+ this.typedrouter.addTypedHandler(
82
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkHosterDomains>(
83
+ 'getWorkHosterDomains',
84
+ async (dataArg) => {
85
+ const auth = await this.requireAuth(dataArg, 'workhosters:read');
86
+ this.assertCapability(auth, 'readDomains');
87
+ return { domains: await this.listGatewayClientDomains(auth) };
88
+ },
89
+ ),
90
+ );
91
+
92
+ this.typedrouter.addTypedHandler(
93
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncGatewayClientRoute>(
94
+ 'syncGatewayClientRoute',
95
+ async (dataArg) => {
96
+ const auth = await this.requireAuth(dataArg, 'gateway-clients:write');
97
+ this.assertCapability(auth, 'syncRoutes');
98
+ return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete);
99
+ },
100
+ ),
101
+ );
102
+
103
+ this.typedrouter.addTypedHandler(
104
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppRoute>(
105
+ 'syncWorkAppRoute',
106
+ async (dataArg) => {
107
+ const auth = await this.requireAuth(dataArg, 'workhosters:write');
108
+ this.assertCapability(auth, 'syncRoutes');
109
+ const ownership: interfaces.data.IGatewayClientOwnership = {
110
+ gatewayClientType: dataArg.ownership.workHosterType,
111
+ gatewayClientId: dataArg.ownership.workHosterId,
112
+ appId: dataArg.ownership.workAppId,
113
+ hostname: dataArg.ownership.hostname,
114
+ };
115
+ return await this.syncGatewayClientRoute(auth, ownership, dataArg.route, dataArg.enabled, dataArg.delete);
116
+ },
117
+ ),
118
+ );
119
+
120
+ this.typedrouter.addTypedHandler(
121
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkAppMailIdentities>(
122
+ 'getWorkAppMailIdentities',
123
+ async (dataArg) => {
124
+ await this.requireAuth(dataArg, 'workhosters:read');
125
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
126
+ if (!manager) return { identities: [] };
127
+ return { identities: await manager.listMailIdentities(dataArg.ownership) };
128
+ },
129
+ ),
130
+ );
131
+
132
+ this.typedrouter.addTypedHandler(
133
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppMailIdentity>(
134
+ 'syncWorkAppMailIdentity',
135
+ async (dataArg) => {
136
+ const auth = await this.requireAuth(dataArg, 'workhosters:write');
137
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
138
+ if (!manager) {
139
+ return { success: false, message: 'WorkApp mail manager not initialized' };
140
+ }
141
+ try {
142
+ return await manager.syncMailIdentity(dataArg, auth.userId);
143
+ } catch (error) {
144
+ return { success: false, message: (error as Error).message };
145
+ }
146
+ },
147
+ ),
148
+ );
149
+ }
150
+
151
+ private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities {
152
+ const dcRouter = this.opsServerRef.dcRouterRef;
153
+ return {
154
+ routes: {
155
+ read: Boolean(dcRouter.routeConfigManager),
156
+ write: Boolean(dcRouter.routeConfigManager),
157
+ idempotentSync: Boolean(dcRouter.routeConfigManager),
158
+ },
159
+ domains: {
160
+ read: Boolean(dcRouter.dnsManager),
161
+ write: Boolean(dcRouter.dnsManager),
162
+ },
163
+ certificates: {
164
+ read: Boolean(dcRouter.smartProxy),
165
+ export: Boolean(dcRouter.smartProxy),
166
+ forceRenew: Boolean(dcRouter.smartProxy),
167
+ },
168
+ email: {
169
+ domains: Boolean(dcRouter.emailDomainManager),
170
+ inbound: Boolean(dcRouter.emailServer),
171
+ outbound: Boolean(dcRouter.emailServer),
172
+ },
173
+ remoteIngress: {
174
+ enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled),
175
+ },
176
+ dns: {
177
+ authoritative: Boolean(dcRouter.options.dnsScopes?.length),
178
+ providerManaged: Boolean(dcRouter.dnsManager),
179
+ },
180
+ http3: {
181
+ enabled: dcRouter.options.http3?.enabled !== false,
182
+ },
183
+ };
184
+ }
185
+
186
+ private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string {
187
+ return [
188
+ ownership.workHosterType,
189
+ ownership.workHosterId,
190
+ ownership.workAppId,
191
+ ownership.hostname,
192
+ ].map((part) => part.trim()).join(':');
193
+ }
194
+
195
+ private assertCapability(
196
+ auth: TAuthContext,
197
+ capability: keyof NonNullable<interfaces.data.IApiTokenPolicy['capabilities']>,
198
+ ): void {
199
+ if (auth.isAdmin) return;
200
+ const policy = auth.token?.policy;
201
+ if (!policy || policy.role !== 'gatewayClient') return;
202
+ if (policy.capabilities?.[capability] === true) return;
203
+ throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
204
+ }
205
+
206
+ private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
207
+ const policyClient = auth.token?.policy?.gatewayClient;
208
+ if (!policyClient) return requestedId;
209
+ if (requestedId && requestedId !== policyClient.id) {
210
+ throw new plugins.typedrequest.TypedResponseError('gateway client token cannot access another gateway client');
211
+ }
212
+ return policyClient.id;
213
+ }
214
+
215
+ private assertGatewayClientOwnership(auth: TAuthContext, ownership: interfaces.data.IGatewayClientOwnership): void {
216
+ const policy = auth.token?.policy;
217
+ if (!policy || policy.role !== 'gatewayClient') return;
218
+ if (!policy.gatewayClient) {
219
+ throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
220
+ }
221
+ if (ownership.gatewayClientType !== policy.gatewayClient.type || ownership.gatewayClientId !== policy.gatewayClient.id) {
222
+ throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
223
+ }
224
+ if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
225
+ throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
226
+ }
227
+ }
228
+
229
+ private assertRouteTargetsAllowed(auth: TAuthContext, route?: interfaces.data.IDcRouterRouteConfig): void {
230
+ const policy = auth.token?.policy;
231
+ if (!policy || policy.role !== 'gatewayClient' || !route) return;
232
+ const allowedTargets = policy.allowedRouteTargets || [];
233
+ if (allowedTargets.length === 0) {
234
+ throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
235
+ }
236
+ const targets = ((route.action as any)?.targets || []) as Array<{ host?: string; port?: number }>;
237
+ for (const target of targets) {
238
+ const host = String(target.host || '').trim().toLowerCase();
239
+ const port = Number(target.port);
240
+ const allowed = allowedTargets.some((allowedTarget) => {
241
+ return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
242
+ });
243
+ if (!allowed) {
244
+ throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`);
245
+ }
246
+ }
247
+ }
248
+
249
+ private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
250
+ const normalizedHostname = hostname.trim().toLowerCase();
251
+ if (!normalizedHostname) return false;
252
+ for (const pattern of patterns) {
253
+ const normalizedPattern = pattern.trim().toLowerCase();
254
+ if (!normalizedPattern) continue;
255
+ if (normalizedPattern === normalizedHostname) return true;
256
+ if (normalizedPattern.startsWith('*.')) {
257
+ const suffix = normalizedPattern.slice(2);
258
+ if (!normalizedHostname.endsWith(`.${suffix}`)) continue;
259
+ const prefix = normalizedHostname.slice(0, -(suffix.length + 1));
260
+ if (prefix && !prefix.includes('.')) return true;
261
+ }
262
+ }
263
+ return false;
264
+ }
265
+
266
+ private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] {
267
+ const domains = (route.match as any)?.domains;
268
+ if (Array.isArray(domains)) {
269
+ return domains.map((domain) => String(domain).trim().toLowerCase()).filter(Boolean);
270
+ }
271
+ if (typeof domains === 'string') {
272
+ return domains.split(',').map((domain) => domain.trim().toLowerCase()).filter(Boolean);
273
+ }
274
+ return [];
275
+ }
276
+
277
+ private getOwnedRoutes(gatewayClientId?: string): interfaces.data.IMergedRoute[] {
278
+ const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
279
+ if (!manager) return [];
280
+ return manager.getMergedRoutes().routes.filter((route) => {
281
+ const metadata = route.metadata;
282
+ if (!metadata) return false;
283
+ const ownerType = metadata.ownerType;
284
+ const isGatewayOwned = ownerType === 'gatewayClient' || ownerType === 'workhoster';
285
+ if (!isGatewayOwned) return false;
286
+ const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId;
287
+ return gatewayClientId ? routeGatewayClientId === gatewayClientId : true;
288
+ });
289
+ }
290
+
291
+ private async listGatewayClientDomains(
292
+ auth: TAuthContext,
293
+ requestedGatewayClientId?: string,
294
+ ): Promise<interfaces.data.IGatewayClientDomain[]> {
295
+ const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
296
+ if (!dnsManager) return [];
297
+ const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
298
+ const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
299
+ const routeHostnames = ownedRoutes.flatMap((route) => this.getRouteHostnames(route.route));
300
+ const docs = await dnsManager.listDomains();
301
+
302
+ return docs
303
+ .filter((domainDoc) => {
304
+ if (!auth.token?.policy || auth.token.policy.role !== 'gatewayClient') return true;
305
+ return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name));
306
+ })
307
+ .map((domainDoc) => {
308
+ const domain = dnsManager.toPublicDomain(domainDoc);
309
+ const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId);
310
+ const serviceCount = routeHostnames.filter((hostname) => this.isHostnameInDomain(hostname, domain.name)).length;
311
+ return {
312
+ ...domain,
313
+ serviceCount,
314
+ managePath: `/domains/${domain.id}`,
315
+ capabilities: {
316
+ canCreateSubdomains: canManageDnsRecords,
317
+ canManageDnsRecords,
318
+ canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy),
319
+ canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager),
320
+ },
321
+ } satisfies interfaces.data.IGatewayClientDomain;
322
+ });
323
+ }
324
+
325
+ private async listGatewayClientDnsRecords(
326
+ auth: TAuthContext,
327
+ requestedGatewayClientId?: string,
328
+ ): Promise<interfaces.data.IGatewayClientDnsRecord[]> {
329
+ const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
330
+ if (!dnsManager) return [];
331
+ const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
332
+ const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
333
+ const domains = await dnsManager.listDomains();
334
+ const records: interfaces.data.IGatewayClientDnsRecord[] = [];
335
+
336
+ for (const route of ownedRoutes) {
337
+ const metadata = route.metadata;
338
+ if (!metadata) continue;
339
+ const gatewayClientType = metadata.gatewayClientType || metadata.workHosterType || 'custom';
340
+ const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId || '';
341
+ const appId = metadata.gatewayClientAppId || metadata.workAppId || '';
342
+
343
+ for (const hostname of this.getRouteHostnames(route.route)) {
344
+ if (auth.token?.policy?.role === 'gatewayClient' && !this.matchesHostnamePatterns(hostname, auth.token.policy.hostnamePatterns || [])) {
345
+ continue;
346
+ }
347
+ const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name));
348
+ const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : [];
349
+ const matchingRecords = domainRecords.filter((record) => record.name === hostname);
350
+ if (matchingRecords.length === 0) {
351
+ records.push({
352
+ id: `missing:${hostname}`,
353
+ domainId: domainDoc?.id || '',
354
+ domainName: domainDoc?.name,
355
+ name: hostname,
356
+ type: 'MISSING',
357
+ value: '',
358
+ ttl: 0,
359
+ source: 'local',
360
+ status: 'missing',
361
+ gatewayClientType,
362
+ gatewayClientId: routeGatewayClientId,
363
+ appId,
364
+ hostname,
365
+ routeId: route.id,
366
+ managePath: domainDoc ? `/domains/${domainDoc.id}/dns` : '/domains',
367
+ createdAt: route.createdAt || 0,
368
+ updatedAt: route.updatedAt || 0,
369
+ createdBy: '',
370
+ });
371
+ continue;
372
+ }
373
+ for (const recordDoc of matchingRecords) {
374
+ const record = dnsManager.toPublicRecord(recordDoc);
375
+ records.push({
376
+ ...record,
377
+ domainName: domainDoc?.name,
378
+ status: 'active',
379
+ gatewayClientType,
380
+ gatewayClientId: routeGatewayClientId,
381
+ appId,
382
+ hostname,
383
+ routeId: route.id,
384
+ managePath: `/dns-records/${record.id}`,
385
+ });
386
+ }
387
+ }
388
+ }
389
+
390
+ return records;
391
+ }
392
+
393
+ private isHostnameInDomain(hostname: string, domainName: string): boolean {
394
+ const normalizedHostname = hostname.trim().toLowerCase();
395
+ const normalizedDomainName = domainName.trim().toLowerCase();
396
+ return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`);
397
+ }
398
+
399
+ private async syncGatewayClientRoute(
400
+ auth: TAuthContext,
401
+ ownership: interfaces.data.IGatewayClientOwnership,
402
+ route?: interfaces.data.IDcRouterRouteConfig,
403
+ enabled?: boolean,
404
+ deleteRoute?: boolean,
405
+ ): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
406
+ this.assertGatewayClientOwnership(auth, ownership);
407
+ this.assertRouteTargetsAllowed(auth, route);
408
+
409
+ const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
410
+ if (!manager) {
411
+ return { success: false, message: 'Route management not initialized' };
412
+ }
413
+
414
+ const externalKey = this.buildGatewayClientExternalKey(ownership);
415
+ const existingRoute = manager.findApiRouteByExternalKey(externalKey);
416
+
417
+ if (deleteRoute) {
418
+ if (!existingRoute) {
419
+ return { success: true, action: 'unchanged' };
420
+ }
421
+ const result = await manager.deleteRoute(existingRoute.id);
422
+ return result.success
423
+ ? { success: true, action: 'deleted', routeId: existingRoute.id }
424
+ : { success: false, message: result.message };
425
+ }
426
+
427
+ if (!route) {
428
+ return { success: false, message: 'route is required unless delete=true' };
429
+ }
430
+
431
+ const metadata: interfaces.data.IRouteMetadata = {
432
+ ownerType: 'gatewayClient',
433
+ gatewayClientType: ownership.gatewayClientType,
434
+ gatewayClientId: ownership.gatewayClientId,
435
+ gatewayClientAppId: ownership.appId,
436
+ workHosterType: ownership.gatewayClientType,
437
+ workHosterId: ownership.gatewayClientId,
438
+ workAppId: ownership.appId,
439
+ externalKey,
440
+ };
441
+ const normalizedRoute = this.normalizeGatewayClientRoute(route, ownership, externalKey);
442
+
443
+ if (existingRoute) {
444
+ const result = await manager.updateRoute(existingRoute.id, {
445
+ route: normalizedRoute,
446
+ enabled: enabled ?? true,
447
+ metadata,
448
+ });
449
+ return result.success
450
+ ? { success: true, action: 'updated', routeId: existingRoute.id }
451
+ : { success: false, message: result.message };
452
+ }
453
+
454
+ const routeId = await manager.createRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
455
+ return { success: true, action: 'created', routeId };
456
+ }
457
+
458
+ private buildGatewayClientExternalKey(ownership: interfaces.data.IGatewayClientOwnership): string {
459
+ return [
460
+ ownership.gatewayClientType,
461
+ ownership.gatewayClientId,
462
+ ownership.appId,
463
+ ownership.hostname,
464
+ ].map((part) => part.trim()).join(':');
465
+ }
466
+
467
+ private normalizeWorkAppRoute(
468
+ route: interfaces.data.IDcRouterRouteConfig,
469
+ ownership: interfaces.data.IWorkAppRouteOwnership,
470
+ externalKey: string,
471
+ ): interfaces.data.IDcRouterRouteConfig {
472
+ const normalizedRoute = { ...route };
473
+ if (!normalizedRoute.name) {
474
+ normalizedRoute.name = `workapp-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
475
+ }
476
+ return normalizedRoute;
477
+ }
478
+
479
+ private normalizeGatewayClientRoute(
480
+ route: interfaces.data.IDcRouterRouteConfig,
481
+ ownership: interfaces.data.IGatewayClientOwnership,
482
+ externalKey: string,
483
+ ): interfaces.data.IDcRouterRouteConfig {
484
+ const normalizedRoute = { ...route };
485
+ if (!normalizedRoute.name) {
486
+ normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
487
+ }
488
+ return normalizedRoute;
489
+ }
490
+ }