import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; type TAuthContext = { userId: string; isAdmin: boolean; token?: interfaces.data.IStoredApiToken; }; export class WorkHosterHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); constructor(private opsServerRef: OpsServer) { this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); } private async requireAuth( request: { identity?: interfaces.data.IIdentity; apiToken?: string }, requiredScope?: interfaces.data.TApiTokenScope, ): Promise { if (request.identity?.jwt) { try { const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ identity: request.identity, }); if (isAdmin) return { userId: request.identity.userId, isAdmin: true }; } catch { /* fall through */ } } 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 { userId: token.createdBy, isAdmin: token.policy?.role === 'admin', token }; } throw new plugins.typedrequest.TypedResponseError('insufficient scope'); } } } throw new plugins.typedrequest.TypedResponseError('unauthorized'); } private async requireAdmin(request: { identity?: interfaces.data.IIdentity }): Promise { if (request.identity?.jwt) { const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ identity: request.identity, }); if (isAdmin) return request.identity.userId; } throw new plugins.typedrequest.TypedResponseError('admin identity required'); } private registerHandlers(): void { this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayCapabilities', async (dataArg) => { await this.requireAuth(dataArg, 'gateway-clients:read'); return { capabilities: this.getGatewayCapabilities() }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientContext', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read'); return { context: this.getGatewayClientContext(auth), capabilities: this.getGatewayCapabilities(), }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'listGatewayClients', async (dataArg) => { await this.requireAdmin(dataArg); return { gatewayClients: await this.listManagedGatewayClients() }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createGatewayClient', async (dataArg) => { const userId = await this.requireAdmin(dataArg); const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; if (!manager) return { success: false, message: 'Gateway client management not initialized' }; try { const gatewayClient = await manager.createClient({ id: dataArg.id, type: dataArg.type, name: dataArg.name, description: dataArg.description, hostnamePatterns: dataArg.hostnamePatterns, allowedRouteTargets: dataArg.allowedRouteTargets, capabilities: dataArg.capabilities, createdBy: userId, }); return { success: true, gatewayClient }; } catch (error) { return { success: false, message: (error as Error).message }; } }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'updateGatewayClient', async (dataArg) => { await this.requireAdmin(dataArg); const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; if (!manager) return { success: false, message: 'Gateway client management not initialized' }; const gatewayClient = await manager.updateClient(dataArg.id, { name: dataArg.name, description: dataArg.description, hostnamePatterns: dataArg.hostnamePatterns, allowedRouteTargets: dataArg.allowedRouteTargets, capabilities: dataArg.capabilities, enabled: dataArg.enabled, }); return gatewayClient ? { success: true, gatewayClient } : { success: false, message: 'Gateway client not found' }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteGatewayClient', async (dataArg) => { await this.requireAdmin(dataArg); const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; if (!manager) return { success: false, message: 'Gateway client management not initialized' }; const success = await manager.deleteClient(dataArg.id); return { success, message: success ? undefined : 'Gateway client not found' }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createGatewayClientToken', async (dataArg) => { const userId = await this.requireAdmin(dataArg); const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId); const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; if (!gatewayClient || !gatewayClient.enabled) { return { success: false, message: 'Gateway client not found or disabled' }; } if (!tokenManager) { return { success: false, message: 'Token management not initialized' }; } const result = await tokenManager.createToken( dataArg.name?.trim() || `${gatewayClient.name} Token`, ['gateway-clients:read', 'gateway-clients:write'], dataArg.expiresInDays ?? null, userId, { role: 'gatewayClient', scopes: ['gateway-clients:read', 'gateway-clients:write'], gatewayClient: { type: gatewayClient.type, id: gatewayClient.id }, hostnamePatterns: gatewayClient.hostnamePatterns, allowedRouteTargets: gatewayClient.allowedRouteTargets, capabilities: gatewayClient.capabilities, }, ); return { success: true, tokenId: result.id, tokenValue: result.rawToken }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientDomains', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read'); this.assertCapability(auth, 'readDomains'); return { domains: await this.listGatewayClientDomains(auth, dataArg.gatewayClientId) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientDnsRecords', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read'); this.assertCapability(auth, 'readDnsRecords'); return { records: await this.listGatewayClientDnsRecords(auth, dataArg.gatewayClientId) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getWorkHosterDomains', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'workhosters:read'); this.assertCapability(auth, 'readDomains'); return { domains: await this.listGatewayClientDomains(auth) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'syncGatewayClientRoute', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:write'); this.assertCapability(auth, 'syncRoutes'); return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'syncWorkAppRoute', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'workhosters:write'); this.assertCapability(auth, 'syncRoutes'); const ownership: interfaces.data.IGatewayClientOwnership = { gatewayClientType: dataArg.ownership.workHosterType, gatewayClientId: dataArg.ownership.workHosterId, appId: dataArg.ownership.workAppId, hostname: dataArg.ownership.hostname, }; return await this.syncGatewayClientRoute(auth, ownership, dataArg.route, dataArg.enabled, dataArg.delete); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getWorkAppMailIdentities', async (dataArg) => { await this.requireAuth(dataArg, 'workhosters:read'); const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) return { identities: [] }; return { identities: await manager.listMailIdentities(dataArg.ownership) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'syncWorkAppMailIdentity', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'workhosters:write'); const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) { return { success: false, message: 'WorkApp mail manager not initialized' }; } try { return await manager.syncMailIdentity(dataArg, auth.userId); } catch (error) { return { success: false, message: (error as Error).message }; } }, ), ); } private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities { const dcRouter = this.opsServerRef.dcRouterRef; return { routes: { read: Boolean(dcRouter.routeConfigManager), write: Boolean(dcRouter.routeConfigManager), idempotentSync: Boolean(dcRouter.routeConfigManager), }, domains: { read: Boolean(dcRouter.dnsManager), write: Boolean(dcRouter.dnsManager), }, certificates: { read: Boolean(dcRouter.smartProxy), export: Boolean(dcRouter.smartProxy), forceRenew: Boolean(dcRouter.smartProxy), }, email: { domains: Boolean(dcRouter.emailDomainManager), inbound: Boolean(dcRouter.emailServer), outbound: Boolean(dcRouter.emailServer), }, remoteIngress: { enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled), }, dns: { authoritative: Boolean(dcRouter.options.dnsScopes?.length), providerManaged: Boolean(dcRouter.dnsManager), }, http3: { enabled: dcRouter.options.http3?.enabled !== false, }, }; } private getGatewayClientContext(auth: TAuthContext): interfaces.data.IGatewayClientContext { const policy = auth.token?.policy; const role = auth.isAdmin ? 'admin' : policy?.role || 'operator'; return { role, scopes: auth.token?.scopes || ['*'], gatewayClient: policy?.gatewayClient, hostnamePatterns: policy?.hostnamePatterns || [], allowedRouteTargets: policy?.allowedRouteTargets || [], capabilities: policy?.capabilities || {}, }; } private async listManagedGatewayClients(): Promise { const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; if (!manager) return []; const clients = await manager.listClients(); const tokens = this.opsServerRef.dcRouterRef.apiTokenManager?.listTokens() || []; return clients.map((client) => ({ ...client, tokenCount: tokens.filter((token) => token.policy?.gatewayClient?.id === client.id).length, })); } private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string { return [ ownership.workHosterType, ownership.workHosterId, ownership.workAppId, ownership.hostname, ].map((part) => part.trim()).join(':'); } private assertCapability( auth: TAuthContext, capability: keyof NonNullable, ): void { if (auth.isAdmin) return; const policy = auth.token?.policy; if (!policy || policy.role !== 'gatewayClient') return; if (policy.capabilities?.[capability] === true) return; throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`); } private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined { const policyClient = auth.token?.policy?.gatewayClient; if (!policyClient) return requestedId; if (requestedId && requestedId !== policyClient.id) { throw new plugins.typedrequest.TypedResponseError('gateway client token cannot access another gateway client'); } return policyClient.id; } private resolveGatewayClientOwnership( auth: TAuthContext, ownership: interfaces.data.IGatewayClientOwnership, ): Required { const policy = auth.token?.policy; if (policy?.role === 'gatewayClient') { if (!policy.gatewayClient) { throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding'); } if (ownership.gatewayClientType && ownership.gatewayClientType !== policy.gatewayClient.type) { throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership'); } if (ownership.gatewayClientId && ownership.gatewayClientId !== policy.gatewayClient.id) { throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership'); } return { gatewayClientType: policy.gatewayClient.type, gatewayClientId: policy.gatewayClient.id, appId: ownership.appId, hostname: ownership.hostname, }; } if (!ownership.gatewayClientType || !ownership.gatewayClientId) { throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing type or id'); } return ownership as Required; } private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required): void { const policy = auth.token?.policy; if (!policy || policy.role !== 'gatewayClient') return; if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) { throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy'); } } private assertRouteTargetsAllowed(auth: TAuthContext, route?: interfaces.data.IDcRouterRouteConfig): void { const policy = auth.token?.policy; if (!policy || policy.role !== 'gatewayClient' || !route) return; const allowedTargets = policy.allowedRouteTargets || []; if (allowedTargets.length === 0) { throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets'); } const targets = ((route.action as any)?.targets || []) as Array<{ host?: string; port?: number }>; for (const target of targets) { const host = String(target.host || '').trim().toLowerCase(); const port = Number(target.port); const allowed = allowedTargets.some((allowedTarget) => { return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port); }); if (!allowed) { throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`); } } } private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean { const normalizedHostname = hostname.trim().toLowerCase(); if (!normalizedHostname) return false; for (const pattern of patterns) { const normalizedPattern = pattern.trim().toLowerCase(); if (!normalizedPattern) continue; if (normalizedPattern === normalizedHostname) return true; if (normalizedPattern.startsWith('*.')) { const suffix = normalizedPattern.slice(2); if (!normalizedHostname.endsWith(`.${suffix}`)) continue; const prefix = normalizedHostname.slice(0, -(suffix.length + 1)); if (prefix && !prefix.includes('.')) return true; } } return false; } private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] { const domains = (route.match as any)?.domains; if (Array.isArray(domains)) { return domains.map((domain) => String(domain).trim().toLowerCase()).filter(Boolean); } if (typeof domains === 'string') { return domains.split(',').map((domain) => domain.trim().toLowerCase()).filter(Boolean); } return []; } private getOwnedRoutes(gatewayClientId?: string): interfaces.data.IMergedRoute[] { const manager = this.opsServerRef.dcRouterRef.routeConfigManager; if (!manager) return []; return manager.getMergedRoutes().routes.filter((route) => { const metadata = route.metadata; if (!metadata) return false; const ownerType = metadata.ownerType; const isGatewayOwned = ownerType === 'gatewayClient' || ownerType === 'workhoster'; if (!isGatewayOwned) return false; const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId; return gatewayClientId ? routeGatewayClientId === gatewayClientId : true; }); } private async listGatewayClientDomains( auth: TAuthContext, requestedGatewayClientId?: string, ): Promise { const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return []; const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId); const ownedRoutes = this.getOwnedRoutes(gatewayClientId); const routeHostnames = ownedRoutes.flatMap((route) => this.getRouteHostnames(route.route)); const docs = await dnsManager.listDomains(); return docs .filter((domainDoc) => { if (!auth.token?.policy || auth.token.policy.role !== 'gatewayClient') return true; return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name)); }) .map((domainDoc) => { const domain = dnsManager.toPublicDomain(domainDoc); const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId); const serviceCount = routeHostnames.filter((hostname) => this.isHostnameInDomain(hostname, domain.name)).length; return { ...domain, serviceCount, managePath: `/domains/${domain.id}`, capabilities: { canCreateSubdomains: canManageDnsRecords, canManageDnsRecords, canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy), canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager), }, } satisfies interfaces.data.IGatewayClientDomain; }); } private async listGatewayClientDnsRecords( auth: TAuthContext, requestedGatewayClientId?: string, ): Promise { const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return []; const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId); const ownedRoutes = this.getOwnedRoutes(gatewayClientId); const domains = await dnsManager.listDomains(); const records: interfaces.data.IGatewayClientDnsRecord[] = []; for (const route of ownedRoutes) { const metadata = route.metadata; if (!metadata) continue; const gatewayClientType = metadata.gatewayClientType || metadata.workHosterType || 'custom'; const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId || ''; const appId = metadata.gatewayClientAppId || metadata.workAppId || ''; for (const hostname of this.getRouteHostnames(route.route)) { if (auth.token?.policy?.role === 'gatewayClient' && !this.matchesHostnamePatterns(hostname, auth.token.policy.hostnamePatterns || [])) { continue; } const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name)); const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : []; const matchingRecords = domainRecords.filter((record) => record.name === hostname); if (matchingRecords.length === 0) { records.push({ id: `missing:${hostname}`, domainId: domainDoc?.id || '', domainName: domainDoc?.name, name: hostname, type: 'MISSING', value: '', ttl: 0, source: 'local', status: 'missing', gatewayClientType, gatewayClientId: routeGatewayClientId, appId, hostname, routeId: route.id, managePath: domainDoc ? `/domains/${domainDoc.id}/dns` : '/domains', createdAt: route.createdAt || 0, updatedAt: route.updatedAt || 0, createdBy: '', }); continue; } for (const recordDoc of matchingRecords) { const record = dnsManager.toPublicRecord(recordDoc); records.push({ ...record, domainName: domainDoc?.name, status: 'active', gatewayClientType, gatewayClientId: routeGatewayClientId, appId, hostname, routeId: route.id, managePath: `/dns-records/${record.id}`, }); } } } return records; } private isHostnameInDomain(hostname: string, domainName: string): boolean { const normalizedHostname = hostname.trim().toLowerCase(); const normalizedDomainName = domainName.trim().toLowerCase(); return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`); } private async syncGatewayClientRoute( auth: TAuthContext, ownership: interfaces.data.IGatewayClientOwnership, route?: interfaces.data.IDcRouterRouteConfig, enabled?: boolean, deleteRoute?: boolean, ): Promise { const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership); this.assertGatewayClientOwnership(auth, resolvedOwnership); this.assertRouteTargetsAllowed(auth, route); const manager = this.opsServerRef.dcRouterRef.routeConfigManager; if (!manager) { return { success: false, message: 'Route management not initialized' }; } const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership); const existingRoute = manager.findApiRouteByExternalKey(externalKey); if (deleteRoute) { if (!existingRoute) { return { success: true, action: 'unchanged' }; } const result = await manager.deleteRoute(existingRoute.id); return result.success ? { success: true, action: 'deleted', routeId: existingRoute.id } : { success: false, message: result.message }; } if (!route) { return { success: false, message: 'route is required unless delete=true' }; } const metadata: interfaces.data.IRouteMetadata = { ownerType: 'gatewayClient', gatewayClientType: resolvedOwnership.gatewayClientType, gatewayClientId: resolvedOwnership.gatewayClientId, gatewayClientAppId: resolvedOwnership.appId, workHosterType: resolvedOwnership.gatewayClientType, workHosterId: resolvedOwnership.gatewayClientId, workAppId: resolvedOwnership.appId, externalKey, }; const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey); if (existingRoute) { const result = await manager.updateRoute(existingRoute.id, { route: normalizedRoute, enabled: enabled ?? true, metadata, }); return result.success ? { success: true, action: 'updated', routeId: existingRoute.id } : { success: false, message: result.message }; } const routeId = await manager.createRoute(normalizedRoute, auth.userId, enabled ?? true, metadata); return { success: true, action: 'created', routeId }; } private buildGatewayClientExternalKey(ownership: Required): string { return [ ownership.gatewayClientType, ownership.gatewayClientId, ownership.appId, ownership.hostname, ].map((part) => part.trim()).join(':'); } private normalizeWorkAppRoute( route: interfaces.data.IDcRouterRouteConfig, ownership: interfaces.data.IWorkAppRouteOwnership, externalKey: string, ): interfaces.data.IDcRouterRouteConfig { const normalizedRoute = { ...route }; if (!normalizedRoute.name) { normalizedRoute.name = `workapp-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`; } return normalizedRoute; } private normalizeGatewayClientRoute( route: interfaces.data.IDcRouterRouteConfig, ownership: Required, externalKey: string, ): interfaces.data.IDcRouterRouteConfig { const normalizedRoute = { ...route }; if (!normalizedRoute.name) { normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`; } return normalizedRoute; } }