@serve.zone/dcrouter 13.25.0 → 13.26.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 +2 -2
  7. package/dist_ts/config/classes.api-token-manager.js +31 -3
  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 +32 -1
  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
@@ -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
+ }
package/ts/readme.md CHANGED
@@ -79,7 +79,7 @@ await router.start();
79
79
 
80
80
  ## License and Legal Information
81
81
 
82
- This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
82
+ This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
83
83
 
84
84
  **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
85
85
 
@@ -91,7 +91,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
91
91
 
92
92
  ### Company Information
93
93
 
94
- Task Venture Capital GmbH
94
+ Task Venture Capital GmbH
95
95
  Registered at District Court Bremen HRB 35230 HB, Germany
96
96
 
97
97
  For any legal inquiries or further information, please contact us via email at hello@task.vc.
@@ -10,6 +10,7 @@ import { ConfigManager } from './classes.config.js';
10
10
  import { LogManager } from './classes.logs.js';
11
11
  import { EmailManager } from './classes.email.js';
12
12
  import { RadiusManager } from './classes.radius.js';
13
+ import { WorkHosterManager } from './classes.workhoster.js';
13
14
 
14
15
  export interface IDcRouterApiClientOptions {
15
16
  baseUrl: string;
@@ -31,6 +32,7 @@ export class DcRouterApiClient {
31
32
  public logs: LogManager;
32
33
  public emails: EmailManager;
33
34
  public radius: RadiusManager;
35
+ public workHosters: WorkHosterManager;
34
36
 
35
37
  constructor(options: IDcRouterApiClientOptions) {
36
38
  this.baseUrl = options.baseUrl.replace(/\/+$/, '');
@@ -45,6 +47,7 @@ export class DcRouterApiClient {
45
47
  this.logs = new LogManager(this);
46
48
  this.emails = new EmailManager(this);
47
49
  this.radius = new RadiusManager(this);
50
+ this.workHosters = new WorkHosterManager(this);
48
51
  }
49
52
 
50
53
  // =====================