@serve.zone/dcrouter 17.2.4 → 17.4.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 (113) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +2024 -1758
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +11 -1
  5. package/dist_ts/classes.dcrouter.js +63 -3
  6. package/dist_ts/config/classes.api-token-manager.d.ts +20 -2
  7. package/dist_ts/config/classes.api-token-manager.js +201 -6
  8. package/dist_ts/config/classes.gateway-client-manager.d.ts +36 -2
  9. package/dist_ts/config/classes.gateway-client-manager.js +391 -27
  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/db/documents/classes.cached.email.d.ts +10 -1
  13. package/dist_ts/db/documents/classes.cached.email.js +27 -2
  14. package/dist_ts/db/documents/classes.gateway-client.doc.d.ts +25 -0
  15. package/dist_ts/db/documents/classes.gateway-client.doc.js +73 -2
  16. package/dist_ts/db/documents/classes.ops-config-event.doc.d.ts +55 -0
  17. package/dist_ts/db/documents/classes.ops-config-event.doc.js +296 -0
  18. package/dist_ts/db/documents/index.d.ts +1 -0
  19. package/dist_ts/db/documents/index.js +2 -1
  20. package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +52 -0
  21. package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +609 -0
  22. package/dist_ts/dns/index.d.ts +1 -0
  23. package/dist_ts/dns/index.js +2 -1
  24. package/dist_ts/dns/manager.dns.d.ts +19 -0
  25. package/dist_ts/dns/manager.dns.js +25 -1
  26. package/dist_ts/email/classes.accepted-email-spool.d.ts +8 -1
  27. package/dist_ts/email/classes.accepted-email-spool.js +93 -1
  28. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +7 -1
  29. package/dist_ts/email/classes.smartmta-storage-manager.js +46 -1
  30. package/dist_ts/email/smartmta-storage-policy.js +3 -1
  31. package/dist_ts/monitoring/classes.ops-event-manager.d.ts +32 -0
  32. package/dist_ts/monitoring/classes.ops-event-manager.js +71 -0
  33. package/dist_ts/monitoring/index.d.ts +1 -0
  34. package/dist_ts/monitoring/index.js +2 -1
  35. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  36. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  37. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +1 -0
  38. package/dist_ts/opsserver/handlers/certificate.handler.js +35 -5
  39. package/dist_ts/opsserver/handlers/config-event.handler.d.ts +12 -0
  40. package/dist_ts/opsserver/handlers/config-event.handler.js +47 -0
  41. package/dist_ts/opsserver/handlers/dns-record.handler.d.ts +1 -0
  42. package/dist_ts/opsserver/handlers/dns-record.handler.js +82 -3
  43. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +6 -0
  44. package/dist_ts/opsserver/handlers/email-ops.handler.js +40 -9
  45. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +9 -1
  46. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +714 -162
  47. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  48. package/dist_ts/opsserver/handlers/index.js +2 -1
  49. package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +25 -0
  50. package/dist_ts/opsserver/helpers/gateway-client-auth.js +168 -0
  51. package/dist_ts/plugins.d.ts +2 -1
  52. package/dist_ts/plugins.js +3 -2
  53. package/dist_ts_apiclient/classes.gatewayclient.d.ts +13 -1
  54. package/dist_ts_apiclient/classes.gatewayclient.js +18 -2
  55. package/dist_ts_interfaces/data/config-events.d.ts +34 -0
  56. package/dist_ts_interfaces/data/config-events.js +8 -0
  57. package/dist_ts_interfaces/data/index.d.ts +1 -0
  58. package/dist_ts_interfaces/data/index.js +2 -1
  59. package/dist_ts_interfaces/data/route-management.d.ts +28 -1
  60. package/dist_ts_interfaces/data/route-management.js +3 -1
  61. package/dist_ts_interfaces/data/workhoster.d.ts +1 -0
  62. package/dist_ts_interfaces/requests/config-events.d.ts +35 -0
  63. package/dist_ts_interfaces/requests/config-events.js +4 -0
  64. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  65. package/dist_ts_interfaces/requests/index.js +2 -1
  66. package/dist_ts_interfaces/requests/realtime.d.ts +1 -1
  67. package/dist_ts_interfaces/requests/realtime.js +2 -1
  68. package/dist_ts_migrations/index.js +148 -9
  69. package/dist_ts_web/00_commitinfo_data.js +1 -1
  70. package/dist_ts_web/appstate/config-events.d.ts +19 -0
  71. package/dist_ts_web/appstate/config-events.js +68 -0
  72. package/dist_ts_web/appstate/runtime.js +7 -1
  73. package/dist_ts_web/appstate.d.ts +1 -0
  74. package/dist_ts_web/appstate.js +2 -1
  75. package/dist_ts_web/elements/ops-platform-events.d.ts +21 -0
  76. package/dist_ts_web/elements/ops-platform-events.js +361 -0
  77. package/dist_ts_web/elements/ops-view-logs.d.ts +4 -0
  78. package/dist_ts_web/elements/ops-view-logs.js +96 -9
  79. package/package.json +4 -4
  80. package/readme.plan.md +22 -0
  81. package/ts/00_commitinfo_data.ts +1 -1
  82. package/ts/classes.dcrouter.ts +72 -2
  83. package/ts/config/classes.api-token-manager.ts +237 -4
  84. package/ts/config/classes.gateway-client-manager.ts +463 -19
  85. package/ts/db/documents/classes.api-token.doc.ts +8 -1
  86. package/ts/db/documents/classes.cached.email.ts +21 -1
  87. package/ts/db/documents/classes.gateway-client.doc.ts +86 -0
  88. package/ts/db/documents/classes.ops-config-event.doc.ts +257 -0
  89. package/ts/db/documents/index.ts +1 -0
  90. package/ts/dns/classes.gateway-route-dns-reconciler.ts +801 -0
  91. package/ts/dns/index.ts +1 -0
  92. package/ts/dns/manager.dns.ts +36 -0
  93. package/ts/email/classes.accepted-email-spool.ts +111 -0
  94. package/ts/email/classes.smartmta-storage-manager.ts +57 -1
  95. package/ts/email/smartmta-storage-policy.ts +2 -0
  96. package/ts/monitoring/classes.ops-event-manager.ts +99 -0
  97. package/ts/monitoring/index.ts +2 -1
  98. package/ts/opsserver/classes.opsserver.ts +2 -0
  99. package/ts/opsserver/handlers/certificate.handler.ts +51 -4
  100. package/ts/opsserver/handlers/config-event.handler.ts +62 -0
  101. package/ts/opsserver/handlers/dns-record.handler.ts +101 -2
  102. package/ts/opsserver/handlers/email-ops.handler.ts +42 -8
  103. package/ts/opsserver/handlers/gatewayclient.handler.ts +804 -136
  104. package/ts/opsserver/handlers/index.ts +1 -0
  105. package/ts/opsserver/helpers/gateway-client-auth.ts +198 -0
  106. package/ts/plugins.ts +2 -0
  107. package/ts_apiclient/classes.gatewayclient.ts +45 -0
  108. package/ts_web/00_commitinfo_data.ts +1 -1
  109. package/ts_web/appstate/config-events.ts +98 -0
  110. package/ts_web/appstate/runtime.ts +8 -1
  111. package/ts_web/appstate.ts +1 -0
  112. package/ts_web/elements/ops-platform-events.ts +315 -0
  113. package/ts_web/elements/ops-view-logs.ts +84 -7
@@ -2,12 +2,15 @@ import * as plugins from '../../plugins.js';
2
2
  import type { OpsServer } from '../classes.opsserver.js';
3
3
  import * as interfaces from '../../../ts_interfaces/index.js';
4
4
  import { requireOpsAuth } from '../helpers/auth.js';
5
-
6
- type TAuthContext = {
7
- userId: string;
8
- isAdmin: boolean;
9
- token?: interfaces.data.IStoredApiToken;
10
- };
5
+ import { CachedEmail } from '../../db/index.js';
6
+ import {
7
+ type IGatewayMachineAuthContext as TAuthContext,
8
+ getGatewayCredentialState,
9
+ matchesGatewayHostnamePatterns,
10
+ normalizeGatewayHostname,
11
+ normalizeGatewayTargetHost,
12
+ requireGatewayMachineAuth,
13
+ } from '../helpers/gateway-client-auth.js';
11
14
 
12
15
  type TResolvedGatewayClientOwnership = Required<Pick<plugins.servezoneInterfaces.data.IGatewayClientOwnership,
13
16
  'gatewayClientType' | 'gatewayClientId' | 'appId'
@@ -18,6 +21,8 @@ type TResolvedGatewayClientOwnership = Required<Pick<plugins.servezoneInterfaces
18
21
 
19
22
  export class GatewayClientHandler {
20
23
  public typedrouter = new plugins.typedrequest.TypedRouter();
24
+ private routeSyncChains = new Map<string, Promise<void>>();
25
+ private gatewayClientCredentialChains = new Map<string, Promise<void>>();
21
26
 
22
27
  constructor(private opsServerRef: OpsServer) {
23
28
  this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
@@ -27,12 +32,14 @@ export class GatewayClientHandler {
27
32
  private async requireAuth(
28
33
  request: { identity?: interfaces.data.IIdentity; apiToken?: string },
29
34
  requiredScope?: interfaces.data.TApiTokenScope,
35
+ options: { allowCandidate?: boolean } = {},
30
36
  ): Promise<TAuthContext> {
31
- const auth = await requireOpsAuth(this.opsServerRef, request, {
32
- scope: requiredScope,
33
- requireAdminIdentity: requiredScope?.endsWith(':write'),
34
- });
35
- return { userId: auth.userId, isAdmin: auth.isAdmin, token: auth.token };
37
+ return await requireGatewayMachineAuth(
38
+ this.opsServerRef,
39
+ request,
40
+ requiredScope === 'gateway-clients:write' ? 'gateway-clients:write' : 'gateway-clients:read',
41
+ options,
42
+ );
36
43
  }
37
44
 
38
45
  private async requireAdmin(
@@ -58,11 +65,150 @@ export class GatewayClientHandler {
58
65
  ),
59
66
  );
60
67
 
68
+ this.typedrouter.addTypedHandler(
69
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_ProvisionGatewayClientCredential>(
70
+ 'provisionGatewayClientCredential',
71
+ async (dataArg) => {
72
+ try {
73
+ const userId = await this.requireAdmin(dataArg, 'tokens:manage');
74
+ const clientManager = this.opsServerRef.dcRouterRef.gatewayClientManager;
75
+ const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
76
+ if (!clientManager || !tokenManager) {
77
+ return { success: false, message: 'Gateway client or token management not initialized' };
78
+ }
79
+ const lockKey = this.normalizeGatewayClientLockKey(dataArg.provisioning.id);
80
+ return await this.withGatewayClientCredentialLock(lockKey, async () => {
81
+ if (dataArg.provisioning.enabled === false) {
82
+ return { success: false, message: 'Cannot provision a credential for a disabled gateway client' };
83
+ }
84
+ const provisioned = await clientManager.stageClientProvisioning(
85
+ dataArg.provisioning,
86
+ userId,
87
+ );
88
+ if (!provisioned.gatewayClient.enabled) {
89
+ return { success: false, message: 'Cannot provision a credential for a disabled gateway client' };
90
+ }
91
+ const policy = this.buildGatewayClientTokenPolicy(provisioned.gatewayClient);
92
+ const issuedAt = Date.now();
93
+ const credential = await tokenManager.createProvisionedGatewayCredentialCandidate(
94
+ dataArg.credentialName?.trim() || `${provisioned.gatewayClient.name} Credential`,
95
+ ['gateway-clients:read', 'gateway-clients:write'],
96
+ dataArg.expiresInDays ?? null,
97
+ userId,
98
+ policy,
99
+ provisioned.credentialSequence,
100
+ provisioned.policyDigest,
101
+ );
102
+ return {
103
+ success: true,
104
+ action: provisioned.action,
105
+ gatewayClient: provisioned.gatewayClient,
106
+ credential: {
107
+ tokenId: credential.id,
108
+ tokenValue: credential.rawToken,
109
+ issuedAt,
110
+ state: 'candidate' as const,
111
+ finalizationRequired: true as const,
112
+ },
113
+ };
114
+ });
115
+ } catch (error) {
116
+ return { success: false, message: (error as Error).message };
117
+ }
118
+ },
119
+ ),
120
+ );
121
+
122
+ this.typedrouter.addTypedHandler(
123
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_FinalizeGatewayClientCredential>(
124
+ 'finalizeGatewayClientCredential',
125
+ async (dataArg) => {
126
+ try {
127
+ if ('identity' in dataArg) {
128
+ return { success: false, message: 'Identity authentication cannot finalize gateway credentials' };
129
+ }
130
+ const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
131
+ const clientManager = this.opsServerRef.dcRouterRef.gatewayClientManager;
132
+ if (!tokenManager || !clientManager) {
133
+ return { success: false, message: 'Gateway client or token management not initialized' };
134
+ }
135
+ const initialToken = await tokenManager.validateToken(dataArg.apiToken);
136
+ if (!initialToken) {
137
+ return { success: false, message: 'Candidate gateway credential is invalid or revoked' };
138
+ }
139
+ const gatewayClientId = this.requireFinalizableGatewayCredential(initialToken, dataArg.tokenId);
140
+ return await this.withGatewayClientCredentialLock(gatewayClientId, async () => {
141
+ const lockedToken = await tokenManager.validateToken(dataArg.apiToken);
142
+ if (!lockedToken) {
143
+ return { success: false, message: 'Candidate gateway credential is invalid or revoked' };
144
+ }
145
+ const lockedGatewayClientId = this.requireFinalizableGatewayCredential(
146
+ lockedToken,
147
+ dataArg.tokenId,
148
+ );
149
+ if (lockedGatewayClientId !== gatewayClientId) {
150
+ return { success: false, message: 'Gateway credential ownership changed during finalization' };
151
+ }
152
+ const lifecycle = lockedToken.gatewayCredentialLifecycle!;
153
+ if (lifecycle.cleanupCompletedAt !== null) {
154
+ return {
155
+ success: true,
156
+ gatewayClientId,
157
+ tokenId: dataArg.tokenId,
158
+ revokedCredentialCount: lifecycle.revokedCredentialCount!,
159
+ finalizedAt: lifecycle.finalizedAt!,
160
+ };
161
+ }
162
+ const tokenBinding = lockedToken.policy!.gatewayClient!;
163
+ if (lifecycle.state === 'candidate') {
164
+ const stagedClient = await clientManager.getClientForProvisionedCredential(
165
+ gatewayClientId,
166
+ tokenBinding.type,
167
+ lifecycle,
168
+ );
169
+ if (!stagedClient) {
170
+ return { success: false, message: 'Gateway credential policy was superseded before finalization' };
171
+ }
172
+ }
173
+ const activation = await tokenManager.activateProvisionedGatewayCredential(
174
+ dataArg.tokenId,
175
+ gatewayClientId,
176
+ );
177
+ await clientManager.promoteProvisionedPolicy(
178
+ gatewayClientId,
179
+ tokenBinding.type,
180
+ activation.lifecycle,
181
+ );
182
+ const revokedCredentialCount = await tokenManager.revokeOlderProvisionedGatewayCredentials(
183
+ gatewayClientId,
184
+ activation.lifecycle.sequence,
185
+ dataArg.tokenId,
186
+ );
187
+ const completed = await tokenManager.completeProvisionedGatewayCredentialHandover(
188
+ dataArg.tokenId,
189
+ gatewayClientId,
190
+ revokedCredentialCount,
191
+ );
192
+ return {
193
+ success: true,
194
+ gatewayClientId,
195
+ tokenId: dataArg.tokenId,
196
+ revokedCredentialCount: completed.revokedCredentialCount!,
197
+ finalizedAt: completed.finalizedAt!,
198
+ };
199
+ });
200
+ } catch (error) {
201
+ return { success: false, message: (error as Error).message };
202
+ }
203
+ },
204
+ ),
205
+ );
206
+
61
207
  this.typedrouter.addTypedHandler(
62
208
  new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_GetGatewayClientContext>(
63
209
  'getGatewayClientContext',
64
210
  async (dataArg) => {
65
- const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
211
+ const auth = await this.requireAuth(dataArg, 'gateway-clients:read', { allowCandidate: true });
66
212
  return {
67
213
  context: this.getGatewayClientContext(auth),
68
214
  capabilities: this.getGatewayCapabilities(),
@@ -89,17 +235,22 @@ export class GatewayClientHandler {
89
235
  const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
90
236
  if (!manager) return { success: false, message: 'Gateway client management not initialized' };
91
237
  try {
92
- const gatewayClient = await manager.createClient({
93
- id: dataArg.id,
94
- type: dataArg.type,
95
- name: dataArg.name,
96
- description: dataArg.description,
97
- hostnamePatterns: dataArg.hostnamePatterns,
98
- allowedRouteTargets: dataArg.allowedRouteTargets,
99
- capabilities: dataArg.capabilities,
100
- createdBy: userId,
101
- });
102
- return { success: true, gatewayClient };
238
+ const create = async () => {
239
+ const gatewayClient = await manager.createClient({
240
+ id: dataArg.id,
241
+ type: dataArg.type,
242
+ name: dataArg.name,
243
+ description: dataArg.description,
244
+ hostnamePatterns: dataArg.hostnamePatterns,
245
+ allowedRouteTargets: dataArg.allowedRouteTargets,
246
+ capabilities: dataArg.capabilities,
247
+ createdBy: userId,
248
+ });
249
+ return { success: true, gatewayClient };
250
+ };
251
+ return dataArg.id
252
+ ? await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), create)
253
+ : await create();
103
254
  } catch (error) {
104
255
  return { success: false, message: (error as Error).message };
105
256
  }
@@ -114,17 +265,23 @@ export class GatewayClientHandler {
114
265
  await this.requireAdmin(dataArg);
115
266
  const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
116
267
  if (!manager) return { success: false, message: 'Gateway client management not initialized' };
117
- const gatewayClient = await manager.updateClient(dataArg.id, {
118
- name: dataArg.name,
119
- description: dataArg.description,
120
- hostnamePatterns: dataArg.hostnamePatterns,
121
- allowedRouteTargets: dataArg.allowedRouteTargets,
122
- capabilities: dataArg.capabilities,
123
- enabled: dataArg.enabled,
124
- });
125
- return gatewayClient
126
- ? { success: true, gatewayClient }
127
- : { success: false, message: 'Gateway client not found' };
268
+ try {
269
+ return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), async () => {
270
+ const gatewayClient = await manager.updateClient(dataArg.id, {
271
+ name: dataArg.name,
272
+ description: dataArg.description,
273
+ hostnamePatterns: dataArg.hostnamePatterns,
274
+ allowedRouteTargets: dataArg.allowedRouteTargets,
275
+ capabilities: dataArg.capabilities,
276
+ enabled: dataArg.enabled,
277
+ });
278
+ return gatewayClient
279
+ ? { success: true, gatewayClient }
280
+ : { success: false, message: 'Gateway client not found' };
281
+ });
282
+ } catch (error) {
283
+ return { success: false, message: (error as Error).message };
284
+ }
128
285
  },
129
286
  ),
130
287
  );
@@ -135,9 +292,24 @@ export class GatewayClientHandler {
135
292
  async (dataArg) => {
136
293
  await this.requireAdmin(dataArg);
137
294
  const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
138
- if (!manager) return { success: false, message: 'Gateway client management not initialized' };
139
- const success = await manager.deleteClient(dataArg.id);
140
- return { success, message: success ? undefined : 'Gateway client not found' };
295
+ const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
296
+ if (!manager || !tokenManager) return { success: false, message: 'Gateway client or token management not initialized' };
297
+ try {
298
+ return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), async () => {
299
+ const existing = await manager.getClient(dataArg.id);
300
+ if (!existing) return { success: false, message: 'Gateway client not found' };
301
+ await tokenManager.revokeGatewayClientCredentials(dataArg.id);
302
+ const success = await manager.deleteClient(dataArg.id);
303
+ return {
304
+ success,
305
+ message: success
306
+ ? undefined
307
+ : 'Gateway client changed during credential cleanup; retry deletion',
308
+ };
309
+ });
310
+ } catch (error) {
311
+ return { success: false, message: (error as Error).message };
312
+ }
141
313
  },
142
314
  ),
143
315
  );
@@ -147,29 +319,34 @@ export class GatewayClientHandler {
147
319
  'createGatewayClientToken',
148
320
  async (dataArg) => {
149
321
  const userId = await this.requireAdmin(dataArg, 'tokens:manage');
150
- const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId);
322
+ const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
151
323
  const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
152
- if (!gatewayClient || !gatewayClient.enabled) {
153
- return { success: false, message: 'Gateway client not found or disabled' };
154
- }
155
- if (!tokenManager) {
156
- return { success: false, message: 'Token management not initialized' };
157
- }
158
- const result = await tokenManager.createToken(
159
- dataArg.name?.trim() || `${gatewayClient.name} Token`,
160
- ['gateway-clients:read', 'gateway-clients:write'],
161
- dataArg.expiresInDays ?? null,
162
- userId,
163
- {
164
- role: 'gatewayClient',
165
- scopes: ['gateway-clients:read', 'gateway-clients:write'],
166
- gatewayClient: { type: gatewayClient.type, id: gatewayClient.id },
167
- hostnamePatterns: gatewayClient.hostnamePatterns,
168
- allowedRouteTargets: gatewayClient.allowedRouteTargets,
169
- capabilities: gatewayClient.capabilities,
170
- },
171
- );
172
- return { success: true, tokenId: result.id, tokenValue: result.rawToken };
324
+ if (!manager || !tokenManager) return { success: false, message: 'Gateway client or token management not initialized' };
325
+ return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.gatewayClientId), async () => {
326
+ const gatewayClient = await manager.getClient(dataArg.gatewayClientId);
327
+ if (!gatewayClient || !gatewayClient.enabled) {
328
+ return { success: false, message: 'Gateway client not found or disabled' };
329
+ }
330
+ const result = await tokenManager.createToken(
331
+ dataArg.name?.trim() || `${gatewayClient.name} Token`,
332
+ ['gateway-clients:read', 'gateway-clients:write'],
333
+ dataArg.expiresInDays ?? null,
334
+ userId,
335
+ this.buildGatewayClientTokenPolicy(gatewayClient),
336
+ );
337
+ return { success: true, tokenId: result.id, tokenValue: result.rawToken };
338
+ });
339
+ },
340
+ ),
341
+ );
342
+
343
+ this.typedrouter.addTypedHandler(
344
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_GetGatewayClientMailOverview>(
345
+ 'getGatewayClientMailOverview',
346
+ async (dataArg) => {
347
+ const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
348
+ this.assertCapability(auth, 'readMail');
349
+ return await this.getGatewayClientMailOverview(auth, dataArg.limit);
173
350
  },
174
351
  ),
175
352
  );
@@ -201,7 +378,7 @@ export class GatewayClientHandler {
201
378
  'getGatewayClientRoutes',
202
379
  async (dataArg) => {
203
380
  const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
204
- this.assertCapability(auth, 'readDnsRecords');
381
+ this.assertCapability(auth, 'readRoutes');
205
382
  return { routes: this.listGatewayClientRoutes(auth, dataArg.gatewayClientId) };
206
383
  },
207
384
  ),
@@ -214,7 +391,7 @@ export class GatewayClientHandler {
214
391
  async (dataArg) => {
215
392
  const auth = await this.requireAuth(dataArg, 'gateway-clients:write');
216
393
  this.assertCapability(auth, 'syncRoutes');
217
- return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete, dataArg.sourceProfileRef);
394
+ return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete, dataArg.sourceProfileRef, dataArg.dnsMode);
218
395
  },
219
396
  ),
220
397
  );
@@ -227,6 +404,7 @@ export class GatewayClientHandler {
227
404
  'listMailAddressBindings',
228
405
  async (dataArg) => {
229
406
  const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
407
+ this.assertCapability(auth, 'readMail');
230
408
  const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
231
409
  if (!manager) return { bindings: [] };
232
410
  const bindings = await manager.listMailAddressBindings({
@@ -246,7 +424,7 @@ export class GatewayClientHandler {
246
424
  'syncMailAddressBinding',
247
425
  async (dataArg) => {
248
426
  const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
249
- this.assertCapability(auth, 'syncRoutes');
427
+ this.assertCapability(auth, 'manageMail');
250
428
  const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
251
429
  if (!manager) {
252
430
  return { success: false, message: 'WorkApp mail manager not initialized' };
@@ -271,12 +449,12 @@ export class GatewayClientHandler {
271
449
  'deleteMailAddressBinding',
272
450
  async (dataArg) => {
273
451
  const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
274
- this.assertCapability(auth, 'syncRoutes');
452
+ this.assertCapability(auth, 'manageMail');
275
453
  const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
276
454
  if (!manager) {
277
455
  return { success: false, message: 'WorkApp mail manager not initialized' };
278
456
  }
279
- if (auth.token?.policy?.role === 'gatewayClient') {
457
+ if (auth.policy?.role === 'gatewayClient') {
280
458
  const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({
281
459
  owner: this.resolveMailOwnerFilter(auth),
282
460
  }));
@@ -294,6 +472,7 @@ export class GatewayClientHandler {
294
472
  'listWorkAppMailBindings',
295
473
  async (dataArg) => {
296
474
  const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
475
+ this.assertCapability(auth, 'readMail');
297
476
  const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
298
477
  if (!manager) return { bindings: [] };
299
478
  const owner = this.resolveMailOwnerFilter(auth, dataArg.owner);
@@ -308,13 +487,13 @@ export class GatewayClientHandler {
308
487
  'rotateMailCredential',
309
488
  async (dataArg) => {
310
489
  const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
311
- this.assertCapability(auth, 'syncRoutes');
490
+ this.assertCapability(auth, 'manageMail');
312
491
  const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
313
492
  if (!manager) {
314
493
  return { success: false, message: 'WorkApp mail manager not initialized' };
315
494
  }
316
495
  const owner = this.resolveMailOwnerFilter(auth);
317
- if (auth.token?.policy?.role === 'gatewayClient') {
496
+ if (auth.policy?.role === 'gatewayClient') {
318
497
  const credentialId = dataArg.credentialId?.trim();
319
498
  const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({ owner }));
320
499
  const binding = bindings.find((bindingArg) => bindingArg.outboundIdentityId === credentialId
@@ -369,6 +548,7 @@ export class GatewayClientHandler {
369
548
  }
370
549
 
371
550
  const auth = await this.requireAuth(authData, 'gateway-clients:read');
551
+ this.assertCapability(auth, 'readMail');
372
552
  const status = await manager.getMailDeliveryStatus(dataArg.spoolItemId);
373
553
  if (!this.isMailDeliveryStatusAllowed(auth, status)) {
374
554
  return { attempts: [] };
@@ -433,18 +613,118 @@ export class GatewayClientHandler {
433
613
  }
434
614
 
435
615
  private getGatewayClientContext(auth: TAuthContext): plugins.servezoneInterfaces.data.IGatewayClientContext {
436
- const policy = auth.token?.policy;
437
- const role = auth.isAdmin ? 'admin' : policy?.role || 'operator';
616
+ const policy = auth.policy;
617
+ if (auth.gatewayClient && auth.credentialId) {
618
+ return {
619
+ role: 'gatewayClient',
620
+ credentialState: getGatewayCredentialState(auth.token!),
621
+ credentialId: auth.credentialId,
622
+ scopes: auth.token?.scopes || [],
623
+ gatewayClient: {
624
+ type: auth.gatewayClient.type,
625
+ id: auth.gatewayClient.id,
626
+ policyGeneration: auth.gatewayClient.policyGeneration,
627
+ },
628
+ hostnamePatterns: policy?.hostnamePatterns || [],
629
+ allowedRouteTargets: policy?.allowedRouteTargets || [],
630
+ capabilities: policy?.capabilities || {},
631
+ };
632
+ }
438
633
  return {
439
- role,
634
+ role: 'admin',
440
635
  scopes: auth.token?.scopes || ['*'],
441
- gatewayClient: policy?.gatewayClient,
442
- hostnamePatterns: policy?.hostnamePatterns || [],
443
- allowedRouteTargets: policy?.allowedRouteTargets || [],
444
- capabilities: policy?.capabilities || {},
636
+ hostnamePatterns: [],
637
+ allowedRouteTargets: [],
638
+ capabilities: {},
445
639
  };
446
640
  }
447
641
 
642
+ private buildGatewayClientTokenPolicy(
643
+ gatewayClientArg: interfaces.data.IGatewayClient,
644
+ ): interfaces.data.IApiTokenPolicy {
645
+ return {
646
+ role: 'gatewayClient',
647
+ scopes: ['gateway-clients:read', 'gateway-clients:write'],
648
+ gatewayClient: {
649
+ type: gatewayClientArg.type,
650
+ id: gatewayClientArg.id,
651
+ policyGeneration: gatewayClientArg.policyGeneration,
652
+ },
653
+ hostnamePatterns: gatewayClientArg.hostnamePatterns,
654
+ allowedRouteTargets: gatewayClientArg.allowedRouteTargets,
655
+ capabilities: gatewayClientArg.capabilities,
656
+ };
657
+ }
658
+
659
+ private requireFinalizableGatewayCredential(
660
+ tokenArg: interfaces.data.IStoredApiToken,
661
+ expectedTokenIdArg: string,
662
+ ): string {
663
+ if (tokenArg.id !== expectedTokenIdArg) {
664
+ throw new Error('tokenId does not match the authenticating gateway credential');
665
+ }
666
+ if (tokenArg.policy?.role !== 'gatewayClient' || !tokenArg.policy.gatewayClient?.id) {
667
+ throw new Error('A bound gateway-client credential is required for finalization');
668
+ }
669
+ const lifecycle = tokenArg.gatewayCredentialLifecycle;
670
+ const validCandidate = lifecycle?.source === 'provisioned'
671
+ && lifecycle.state === 'candidate'
672
+ && lifecycle.finalizedAt === null;
673
+ const validActive = lifecycle?.source === 'provisioned'
674
+ && lifecycle.state === 'active'
675
+ && Number.isSafeInteger(lifecycle.finalizedAt)
676
+ && Number(lifecycle.finalizedAt) > 0;
677
+ if (!validCandidate && !validActive) {
678
+ throw new Error('Only provisioned candidate or active gateway credentials can be finalized');
679
+ }
680
+ return this.normalizeGatewayClientLockKey(tokenArg.policy.gatewayClient.id);
681
+ }
682
+
683
+ private async getGatewayClientMailOverview(
684
+ auth: TAuthContext,
685
+ limitArg?: number,
686
+ ): Promise<plugins.servezoneInterfaces.requests.gateway.IReq_GetGatewayClientMailOverview['response']> {
687
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
688
+ if (!manager) return { domains: [], emails: [] };
689
+ const owner = this.resolveMailOwnerFilter(auth);
690
+ const bindings = this.filterMailBindingsAllowed(
691
+ auth,
692
+ await manager.listMailAddressBindings({ owner }),
693
+ );
694
+ const ownedAddresses = new Set(bindings.map((bindingArg) => bindingArg.address.toLowerCase()));
695
+ const ownedDomains = Array.from(new Set(bindings.map((bindingArg) => bindingArg.domain)))
696
+ .sort((left, right) => left.localeCompare(right));
697
+ const domains = await Promise.all(ownedDomains.map(async (domainArg) => {
698
+ const configured = await this.opsServerRef.dcRouterRef.emailDomainManager?.getByDomain(domainArg);
699
+ return {
700
+ domain: domainArg,
701
+ dnsMode: configured ? 'managed' : undefined,
702
+ status: configured?.reconciliation?.lifecycleStatus,
703
+ };
704
+ }));
705
+ const limit = Math.min(Math.max(Math.floor(limitArg ?? 100), 1), 500);
706
+ const recent = this.opsServerRef.dcRouterRef.dcRouterDb?.isReady()
707
+ ? await CachedEmail.findRecent(Math.max(limit * 5, 100))
708
+ : [];
709
+ const emails = recent
710
+ .filter((emailArg) => {
711
+ const from = String(emailArg.from || '').trim().toLowerCase();
712
+ const recipients = (emailArg.to || []).map((addressArg) => addressArg.trim().toLowerCase());
713
+ return ownedAddresses.has(from) || recipients.some((addressArg) => ownedAddresses.has(addressArg));
714
+ })
715
+ .slice(0, limit)
716
+ .map((emailArg) => ({
717
+ id: emailArg.id,
718
+ direction: emailArg.direction,
719
+ from: emailArg.from,
720
+ to: emailArg.to?.[0],
721
+ subject: emailArg.subject,
722
+ status: emailArg.status,
723
+ timestamp: emailArg.acceptedAt,
724
+ }));
725
+ return { domains, emails };
726
+ }
727
+
448
728
  private async listManagedGatewayClients(): Promise<interfaces.data.IGatewayClient[]> {
449
729
  const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
450
730
  if (!manager) return [];
@@ -462,8 +742,10 @@ export class GatewayClientHandler {
462
742
  capability: keyof NonNullable<interfaces.data.IApiTokenPolicy['capabilities']>,
463
743
  ): void {
464
744
  if (auth.isAdmin) return;
465
- const policy = auth.token?.policy;
466
- if (!policy || policy.role !== 'gatewayClient') return;
745
+ const policy = auth.policy;
746
+ if (!policy || policy.role !== 'gatewayClient') {
747
+ throw new plugins.typedrequest.TypedResponseError('gateway-client credential required');
748
+ }
467
749
  if (policy.capabilities?.[capability] === true) return;
468
750
  throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${String(capability)}`);
469
751
  }
@@ -472,7 +754,7 @@ export class GatewayClientHandler {
472
754
  auth: TAuthContext,
473
755
  requestedId?: string,
474
756
  ): { gatewayClientId?: string; gatewayClientType?: string } {
475
- const policy = auth.token?.policy;
757
+ const policy = auth.policy;
476
758
  if (policy?.role !== 'gatewayClient') return { gatewayClientId: requestedId };
477
759
  const policyClient = policy.gatewayClient;
478
760
  if (!policyClient) {
@@ -491,7 +773,7 @@ export class GatewayClientHandler {
491
773
  auth: TAuthContext,
492
774
  ownership: plugins.servezoneInterfaces.data.IGatewayClientOwnership,
493
775
  ): TResolvedGatewayClientOwnership {
494
- const policy = auth.token?.policy;
776
+ const policy = auth.policy;
495
777
  if (!ownership.appId?.trim()) {
496
778
  throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing appId');
497
779
  }
@@ -512,7 +794,7 @@ export class GatewayClientHandler {
512
794
  gatewayClientType: policy.gatewayClient.type,
513
795
  gatewayClientId: policy.gatewayClient.id,
514
796
  appId: ownership.appId,
515
- hostname: ownership.hostname,
797
+ hostname: ownership.hostname ? normalizeGatewayHostname(ownership.hostname) : undefined,
516
798
  routeRef: ownership.routeRef,
517
799
  };
518
800
  }
@@ -527,7 +809,7 @@ export class GatewayClientHandler {
527
809
  auth: TAuthContext,
528
810
  owner?: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner>,
529
811
  ): Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined {
530
- const policy = auth.token?.policy;
812
+ const policy = auth.policy;
531
813
  if (policy?.role !== 'gatewayClient') return owner;
532
814
  if (!policy.gatewayClient) {
533
815
  throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
@@ -557,27 +839,34 @@ export class GatewayClientHandler {
557
839
  }
558
840
 
559
841
  private assertGatewayClientOwnership(auth: TAuthContext, ownership: TResolvedGatewayClientOwnership): void {
560
- const policy = auth.token?.policy;
842
+ const policy = auth.policy;
561
843
  if (!policy || policy.role !== 'gatewayClient') return;
562
844
  if (!ownership.hostname) return;
563
- if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
845
+ if (!matchesGatewayHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [], auth.managedZoneNames)) {
564
846
  throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
565
847
  }
566
848
  }
567
849
 
568
- private assertRouteTargetsAllowed(auth: TAuthContext, route?: interfaces.data.IDcRouterRouteConfig): void {
569
- const policy = auth.token?.policy;
850
+ private assertRouteTargetsAllowed(
851
+ auth: TAuthContext,
852
+ route?: plugins.servezoneInterfaces.data.IGatewayRouteConfig,
853
+ ): void {
854
+ const policy = auth.policy;
570
855
  if (!policy || policy.role !== 'gatewayClient' || !route) return;
571
856
  const allowedTargets = policy.allowedRouteTargets || [];
572
857
  if (allowedTargets.length === 0) {
573
858
  throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
574
859
  }
575
- const targets = ((route.action as any)?.targets || []) as Array<{ host?: string; port?: number }>;
860
+ const targets = route.action.targets;
576
861
  for (const target of targets) {
577
- const host = String(target.host || '').trim().toLowerCase();
862
+ if (typeof target.host !== 'string') {
863
+ throw new plugins.typedrequest.TypedResponseError('gateway route target host must be a string');
864
+ }
865
+ const host = normalizeGatewayTargetHost(target.host);
578
866
  const port = Number(target.port);
579
867
  const allowed = allowedTargets.some((allowedTarget) => {
580
- return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
868
+ return normalizeGatewayTargetHost(allowedTarget.host) === host
869
+ && (allowedTarget.allowAnyPort === true || allowedTarget.ports.includes(port));
581
870
  });
582
871
  if (!allowed) {
583
872
  throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`);
@@ -593,7 +882,7 @@ export class GatewayClientHandler {
593
882
  const targetDescription = target?.smtpForward
594
883
  ? `${target.smtpForward.host.trim().toLowerCase()}:${Number(target.smtpForward.port)}`
595
884
  : 'unknown';
596
- if ((auth.token?.policy?.allowedRouteTargets || []).length === 0) {
885
+ if ((auth.policy?.allowedRouteTargets || []).length === 0) {
597
886
  throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
598
887
  }
599
888
  throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${targetDescription}`);
@@ -603,14 +892,15 @@ export class GatewayClientHandler {
603
892
  auth: TAuthContext,
604
893
  target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
605
894
  ): boolean {
606
- const policy = auth.token?.policy;
895
+ const policy = auth.policy;
607
896
  if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return true;
608
897
  const allowedTargets = policy.allowedRouteTargets || [];
609
898
  if (allowedTargets.length === 0) return false;
610
- const host = target.smtpForward.host.trim().toLowerCase();
899
+ const host = normalizeGatewayTargetHost(target.smtpForward.host);
611
900
  const port = Number(target.smtpForward.port);
612
901
  return allowedTargets.some((allowedTarget) => {
613
- return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
902
+ return normalizeGatewayTargetHost(allowedTarget.host) === host
903
+ && (allowedTarget.allowAnyPort === true || allowedTarget.ports.includes(port));
614
904
  });
615
905
  }
616
906
 
@@ -641,7 +931,7 @@ export class GatewayClientHandler {
641
931
  auth: TAuthContext,
642
932
  statusArg: plugins.servezoneInterfaces.data.IMailDeliveryStatus,
643
933
  ): boolean {
644
- const policy = auth.token?.policy;
934
+ const policy = auth.policy;
645
935
  if (!policy || policy.role !== 'gatewayClient') {
646
936
  return true;
647
937
  }
@@ -659,7 +949,7 @@ export class GatewayClientHandler {
659
949
  ownerArg: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined,
660
950
  bindingsArg: plugins.servezoneInterfaces.data.IWorkAppMailBinding[],
661
951
  ): Promise<plugins.servezoneInterfaces.data.IWorkAppMailBinding[]> {
662
- if (auth.token?.policy?.role !== 'gatewayClient') return bindingsArg;
952
+ if (auth.policy?.role !== 'gatewayClient') return bindingsArg;
663
953
  const allowedAddressBindings = this.filterMailBindingsAllowed(
664
954
  auth,
665
955
  await managerArg.listMailAddressBindings({ owner: ownerArg }),
@@ -690,42 +980,24 @@ export class GatewayClientHandler {
690
980
  addressArg: string,
691
981
  domainArg: string,
692
982
  ): boolean {
693
- const policy = auth.token?.policy;
983
+ const policy = auth.policy;
694
984
  if (!policy || policy.role !== 'gatewayClient') return true;
695
- const normalizedDomain = domainArg.trim().toLowerCase();
985
+ const normalizedDomain = normalizeGatewayHostname(domainArg);
696
986
  const normalizedAddress = addressArg.trim().toLowerCase();
697
987
  if (!normalizedAddress.endsWith(`@${normalizedDomain}`)) {
698
988
  throw new plugins.typedrequest.TypedResponseError('mail address does not match domain');
699
989
  }
700
990
  const patterns = policy.hostnamePatterns || [];
701
- return this.matchesHostnamePatterns(normalizedDomain, patterns)
702
- || patterns.some((patternArg) => patternArg.trim().toLowerCase() === `*.${normalizedDomain}`);
703
- }
704
-
705
- private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
706
- const normalizedHostname = hostname.trim().toLowerCase();
707
- if (!normalizedHostname) return false;
708
- for (const pattern of patterns) {
709
- const normalizedPattern = pattern.trim().toLowerCase();
710
- if (!normalizedPattern) continue;
711
- if (normalizedPattern === normalizedHostname) return true;
712
- if (normalizedPattern.startsWith('*.')) {
713
- const suffix = normalizedPattern.slice(2);
714
- if (!normalizedHostname.endsWith(`.${suffix}`)) continue;
715
- const prefix = normalizedHostname.slice(0, -(suffix.length + 1));
716
- if (prefix && !prefix.includes('.')) return true;
717
- }
718
- }
719
- return false;
991
+ return matchesGatewayHostnamePatterns(normalizedDomain, patterns, auth.managedZoneNames);
720
992
  }
721
993
 
722
994
  private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] {
723
995
  const domains = (route.match as any)?.domains;
724
996
  if (Array.isArray(domains)) {
725
- return domains.map((domain) => String(domain).trim().toLowerCase()).filter(Boolean);
997
+ return domains.map((domain) => normalizeGatewayHostname(String(domain)));
726
998
  }
727
999
  if (typeof domains === 'string') {
728
- return domains.split(',').map((domain) => domain.trim().toLowerCase()).filter(Boolean);
1000
+ return domains.split(',').map((domain) => normalizeGatewayHostname(domain));
729
1001
  }
730
1002
  return [];
731
1003
  }
@@ -752,9 +1024,9 @@ export class GatewayClientHandler {
752
1024
  .map((routeArg) => this.toGatewayClientRoute(routeArg))
753
1025
  .filter((routeArg): routeArg is plugins.servezoneInterfaces.data.IGatewayClientRoute => {
754
1026
  if (!routeArg) return false;
755
- if (auth.token?.policy?.role !== 'gatewayClient') return true;
1027
+ if (auth.policy?.role !== 'gatewayClient') return true;
756
1028
  return !routeArg.ownership.hostname
757
- || this.matchesHostnamePatterns(routeArg.ownership.hostname, auth.token.policy.hostnamePatterns || []);
1029
+ || matchesGatewayHostnamePatterns(routeArg.ownership.hostname, auth.policy.hostnamePatterns || [], auth.managedZoneNames);
758
1030
  });
759
1031
  }
760
1032
 
@@ -821,7 +1093,7 @@ export class GatewayClientHandler {
821
1093
 
822
1094
  return docs
823
1095
  .filter((domainDoc) => {
824
- if (!auth.token?.policy || auth.token.policy.role !== 'gatewayClient') return true;
1096
+ if (!auth.policy || auth.policy.role !== 'gatewayClient') return true;
825
1097
  return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name));
826
1098
  })
827
1099
  .map((domainDoc) => {
@@ -861,12 +1133,18 @@ export class GatewayClientHandler {
861
1133
  const appId = metadata.gatewayClientAppId || '';
862
1134
 
863
1135
  for (const hostname of this.getRouteHostnames(route.route)) {
864
- if (auth.token?.policy?.role === 'gatewayClient' && !this.matchesHostnamePatterns(hostname, auth.token.policy.hostnamePatterns || [])) {
1136
+ if (auth.policy?.role === 'gatewayClient' && !matchesGatewayHostnamePatterns(hostname, auth.policy.hostnamePatterns || [], auth.managedZoneNames)) {
865
1137
  continue;
866
1138
  }
867
1139
  const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name));
868
1140
  const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : [];
869
- const matchingRecords = domainRecords.filter((record) => record.name === hostname);
1141
+ const matchingRecords = domainRecords.filter((record) => {
1142
+ try {
1143
+ return normalizeGatewayHostname(record.name) === hostname;
1144
+ } catch {
1145
+ return false;
1146
+ }
1147
+ });
870
1148
  if (matchingRecords.length === 0) {
871
1149
  records.push({
872
1150
  id: `missing:${hostname}`,
@@ -911,8 +1189,8 @@ export class GatewayClientHandler {
911
1189
  }
912
1190
 
913
1191
  private isHostnameInDomain(hostname: string, domainName: string): boolean {
914
- const normalizedHostname = hostname.trim().toLowerCase();
915
- const normalizedDomainName = domainName.trim().toLowerCase();
1192
+ const normalizedHostname = normalizeGatewayHostname(hostname);
1193
+ const normalizedDomainName = normalizeGatewayHostname(domainName);
916
1194
  return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`);
917
1195
  }
918
1196
 
@@ -923,31 +1201,144 @@ export class GatewayClientHandler {
923
1201
  enabled?: boolean,
924
1202
  deleteRoute?: boolean,
925
1203
  sourceProfileRef?: string,
1204
+ dnsModeArg?: plugins.servezoneInterfaces.data.TGatewayRouteDnsMode,
926
1205
  ): Promise<plugins.servezoneInterfaces.data.IGatewayRouteSyncResult> {
1206
+ const validatedRoute = route === undefined ? undefined : this.validateGatewayRouteConfig(route);
927
1207
  const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
928
1208
  this.assertGatewayClientOwnership(auth, resolvedOwnership);
929
- this.assertGatewayClientRouteOwnership(resolvedOwnership, route);
930
- this.assertRouteTargetsAllowed(auth, route);
1209
+ this.assertGatewayClientRouteOwnership(resolvedOwnership, validatedRoute);
1210
+ this.assertRouteTargetsAllowed(auth, validatedRoute);
931
1211
 
1212
+ if (dnsModeArg === 'observe') this.assertCapability(auth, 'readDnsRecords');
1213
+ if (dnsModeArg === 'reconcile') this.assertCapability(auth, 'syncDnsRecords');
932
1214
  const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
933
1215
  if (!manager) {
934
1216
  return { success: false, message: 'Route management not initialized' };
935
1217
  }
936
1218
 
937
1219
  const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
1220
+ return await this.withRouteSyncLock(externalKey, async () => {
938
1221
  const existingRoute = manager.findApiRouteByExternalKey(externalKey);
1222
+ const dnsMode = dnsModeArg || existingRoute?.metadata?.gatewayDnsMode || 'skip';
1223
+ const includeDns = dnsModeArg !== undefined || dnsMode !== 'skip';
1224
+ if (dnsMode === 'observe') this.assertCapability(auth, 'readDnsRecords');
1225
+ if (dnsMode === 'reconcile') this.assertCapability(auth, 'syncDnsRecords');
1226
+ const reconcileDnsFor = async (
1227
+ hostnameArg: string | undefined,
1228
+ gatewayClientIdArg: string,
1229
+ externalKeyArg: string,
1230
+ deleteArg = false,
1231
+ effectiveModeArg = dnsMode,
1232
+ ) => {
1233
+ const reconciler = this.opsServerRef.dcRouterRef.gatewayRouteDnsReconciler;
1234
+ if (!reconciler) {
1235
+ return {
1236
+ success: effectiveModeArg === 'skip',
1237
+ retryable: effectiveModeArg !== 'skip',
1238
+ mode: effectiveModeArg,
1239
+ status: effectiveModeArg === 'skip' ? 'skipped' : 'zone-unavailable',
1240
+ hostname: hostnameArg,
1241
+ checkedAt: Date.now(),
1242
+ message: effectiveModeArg === 'skip' ? undefined : 'Gateway route DNS reconciliation is not initialized',
1243
+ } satisfies plugins.servezoneInterfaces.data.IGatewayRouteDnsResult;
1244
+ }
1245
+ try {
1246
+ return await reconciler.reconcileRoute({
1247
+ externalKey: externalKeyArg,
1248
+ hostname: hostnameArg,
1249
+ mode: effectiveModeArg,
1250
+ gatewayClientId: gatewayClientIdArg,
1251
+ delete: deleteArg,
1252
+ });
1253
+ } catch (error) {
1254
+ return {
1255
+ success: false,
1256
+ retryable: true,
1257
+ mode: effectiveModeArg,
1258
+ status: 'mutation-failed',
1259
+ hostname: hostnameArg,
1260
+ checkedAt: Date.now(),
1261
+ message: `Gateway route DNS reconciliation failed: ${(error as Error).message}`,
1262
+ } satisfies plugins.servezoneInterfaces.data.IGatewayRouteDnsResult;
1263
+ }
1264
+ };
1265
+ const reconcileDns = async (deleteArg = false, effectiveModeArg = dnsMode) => {
1266
+ return await reconcileDnsFor(
1267
+ resolvedOwnership.hostname,
1268
+ resolvedOwnership.gatewayClientId,
1269
+ externalKey,
1270
+ deleteArg,
1271
+ effectiveModeArg,
1272
+ );
1273
+ };
939
1274
 
940
1275
  if (deleteRoute) {
941
1276
  if (!existingRoute) {
942
- return { success: true, action: 'unchanged' };
1277
+ const dns = await reconcileDns(true, dnsMode);
1278
+ return dns.success
1279
+ ? { success: true, action: 'unchanged', ...(includeDns ? { dns } : {}) }
1280
+ : { success: false, dns, message: dns.message };
1281
+ }
1282
+ const snapshot = structuredClone(existingRoute);
1283
+ const existingDnsMode = snapshot.metadata?.gatewayDnsMode || dnsMode;
1284
+ const disableResult = await manager.updateManagedRoute(existingRoute.id, {
1285
+ enabled: false,
1286
+ });
1287
+ if (!disableResult.success) {
1288
+ return { success: false, routeId: existingRoute.id, message: disableResult.message };
1289
+ }
1290
+ const dns = await reconcileDnsFor(
1291
+ this.getRouteHostnames(snapshot.route)[0] || resolvedOwnership.hostname,
1292
+ snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId,
1293
+ snapshot.metadata?.externalKey || externalKey,
1294
+ true,
1295
+ existingDnsMode,
1296
+ );
1297
+ if (!dns.success) {
1298
+ const rollback = await manager.updateManagedRoute(existingRoute.id, {
1299
+ route: snapshot.route,
1300
+ enabled: snapshot.enabled,
1301
+ metadata: snapshot.metadata,
1302
+ }, { replaceMetadata: true, replaceRoute: true });
1303
+ if (!rollback.success) {
1304
+ dns.status = 'compensation-failed';
1305
+ dns.message = `${dns.message || 'DNS cleanup failed'}; route rollback failed: ${rollback.message || 'unknown error'}`;
1306
+ }
1307
+ return { success: false, routeId: existingRoute.id, dns, message: dns.message };
943
1308
  }
944
1309
  const result = await manager.deleteManagedRoute(existingRoute.id);
945
- return result.success
946
- ? { success: true, action: 'deleted', routeId: existingRoute.id }
947
- : { success: false, message: result.message };
1310
+ if (result.success) {
1311
+ return { success: true, action: 'deleted', routeId: existingRoute.id, ...(includeDns ? { dns } : {}) };
1312
+ }
1313
+ const rollback = await manager.updateManagedRoute(existingRoute.id, {
1314
+ route: snapshot.route,
1315
+ enabled: snapshot.enabled,
1316
+ metadata: snapshot.metadata,
1317
+ }, { replaceMetadata: true, replaceRoute: true });
1318
+ let restoredDns = dns;
1319
+ if (rollback.success && snapshot.enabled && existingDnsMode === 'reconcile') {
1320
+ restoredDns = await reconcileDnsFor(
1321
+ this.getRouteHostnames(snapshot.route)[0] || resolvedOwnership.hostname,
1322
+ snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId,
1323
+ snapshot.metadata?.externalKey || externalKey,
1324
+ false,
1325
+ 'reconcile',
1326
+ );
1327
+ }
1328
+ return {
1329
+ success: false,
1330
+ dns: rollback.success && restoredDns.success ? dns : {
1331
+ ...restoredDns,
1332
+ status: 'compensation-failed',
1333
+ message: !rollback.success
1334
+ ? `${result.message || 'Route deletion failed'}; route rollback failed: ${rollback.message || 'unknown error'}`
1335
+ : `${result.message || 'Route deletion failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`,
1336
+ },
1337
+ message: result.message,
1338
+ };
948
1339
  }
949
1340
 
950
- if (!route) {
1341
+ if (!validatedRoute) {
951
1342
  return { success: false, message: 'route is required unless delete=true' };
952
1343
  }
953
1344
 
@@ -968,10 +1359,22 @@ export class GatewayClientHandler {
968
1359
  gatewayClientId: resolvedOwnership.gatewayClientId,
969
1360
  gatewayClientAppId: resolvedOwnership.appId,
970
1361
  externalKey,
1362
+ gatewayDnsMode: dnsMode,
971
1363
  };
972
- const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
1364
+ const normalizedRoute = this.normalizeGatewayClientRoute(validatedRoute, resolvedOwnership, externalKey);
973
1365
 
974
1366
  if (existingRoute) {
1367
+ const snapshot = structuredClone(existingRoute);
1368
+ const snapshotHostname = this.getRouteHostnames(snapshot.route)[0];
1369
+ const snapshotDnsMode = snapshot.metadata?.gatewayDnsMode || 'skip';
1370
+ const snapshotGatewayClientId = snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId;
1371
+ const snapshotExternalKey = snapshot.metadata?.externalKey || externalKey;
1372
+ const shouldCleanPreviousDns = snapshotDnsMode === 'reconcile'
1373
+ && Boolean(snapshotHostname)
1374
+ && (
1375
+ snapshotHostname !== resolvedOwnership.hostname
1376
+ || dnsMode !== 'reconcile'
1377
+ );
975
1378
  const result = await manager.updateManagedRoute(existingRoute.id, {
976
1379
  route: normalizedRoute,
977
1380
  enabled: enabled ?? true,
@@ -980,17 +1383,125 @@ export class GatewayClientHandler {
980
1383
  replaceMetadata: true,
981
1384
  replaceRoute: true,
982
1385
  });
983
- return result.success
984
- ? { success: true, action: 'updated', routeId: existingRoute.id }
985
- : { success: false, message: result.message };
1386
+ if (!result.success) return { success: false, message: result.message };
1387
+ if (shouldCleanPreviousDns) {
1388
+ const cleanupDns = await reconcileDnsFor(
1389
+ snapshotHostname,
1390
+ snapshotGatewayClientId,
1391
+ snapshotExternalKey,
1392
+ true,
1393
+ 'reconcile',
1394
+ );
1395
+ if (!cleanupDns.success) {
1396
+ const compensation = await manager.updateManagedRoute(existingRoute.id, {
1397
+ route: snapshot.route,
1398
+ enabled: snapshot.enabled,
1399
+ metadata: snapshot.metadata,
1400
+ }, { replaceMetadata: true, replaceRoute: true });
1401
+ if (compensation.success && snapshot.enabled) {
1402
+ const restoredDns = await reconcileDnsFor(
1403
+ snapshotHostname,
1404
+ snapshotGatewayClientId,
1405
+ snapshotExternalKey,
1406
+ false,
1407
+ 'reconcile',
1408
+ );
1409
+ if (!restoredDns.success) {
1410
+ cleanupDns.status = 'compensation-failed';
1411
+ cleanupDns.message = `${cleanupDns.message || 'DNS cleanup failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`;
1412
+ }
1413
+ } else if (!compensation.success) {
1414
+ cleanupDns.status = 'compensation-failed';
1415
+ cleanupDns.message = `${cleanupDns.message || 'DNS cleanup failed'}; route rollback failed: ${compensation.message || 'unknown error'}`;
1416
+ }
1417
+ return { success: false, routeId: existingRoute.id, dns: cleanupDns, message: cleanupDns.message };
1418
+ }
1419
+ }
1420
+ const dns = await reconcileDns((enabled ?? true) === false);
1421
+ if (dns.success) return { success: true, action: 'updated', routeId: existingRoute.id, ...(includeDns ? { dns } : {}) };
1422
+ const compensation = await manager.updateManagedRoute(existingRoute.id, {
1423
+ route: snapshot.route,
1424
+ enabled: snapshot.enabled,
1425
+ metadata: snapshot.metadata,
1426
+ }, { replaceMetadata: true, replaceRoute: true });
1427
+ if (compensation.success && shouldCleanPreviousDns && snapshot.enabled) {
1428
+ const restoredDns = await reconcileDnsFor(
1429
+ snapshotHostname,
1430
+ snapshotGatewayClientId,
1431
+ snapshotExternalKey,
1432
+ false,
1433
+ 'reconcile',
1434
+ );
1435
+ if (!restoredDns.success) {
1436
+ dns.status = 'compensation-failed';
1437
+ dns.message = `${dns.message || 'DNS reconciliation failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`;
1438
+ }
1439
+ } else if (!compensation.success) {
1440
+ dns.status = 'compensation-failed';
1441
+ dns.message = `${dns.message || 'DNS reconciliation failed'}; route rollback failed: ${compensation.message || 'unknown error'}`;
1442
+ }
1443
+ return { success: false, routeId: existingRoute.id, dns, message: dns.message };
986
1444
  }
987
1445
 
988
1446
  const routeId = await manager.createManagedRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
989
- return { success: true, action: 'created', routeId };
1447
+ const dns = await reconcileDns((enabled ?? true) === false);
1448
+ if (dns.success) return { success: true, action: 'created', routeId, ...(includeDns ? { dns } : {}) };
1449
+ const compensation = await manager.deleteManagedRoute(routeId);
1450
+ if (!compensation.success) {
1451
+ dns.status = 'compensation-failed';
1452
+ dns.message = `${dns.message || 'DNS reconciliation failed'}; route rollback failed: ${compensation.message || 'unknown error'}`;
1453
+ }
1454
+ return { success: false, routeId, dns, message: dns.message };
1455
+ });
1456
+ }
1457
+
1458
+ private normalizeGatewayClientLockKey(idArg: string): string {
1459
+ const normalized = idArg.trim().toLowerCase()
1460
+ .replace(/[^a-z0-9._-]/g, '-')
1461
+ .replace(/-+/g, '-')
1462
+ .replace(/^-|-$/g, '');
1463
+ if (!normalized) throw new Error('gateway client id is required');
1464
+ return normalized;
1465
+ }
1466
+
1467
+ private async withGatewayClientCredentialLock<T>(
1468
+ keyArg: string,
1469
+ taskArg: () => Promise<T>,
1470
+ ): Promise<T> {
1471
+ const previous = this.gatewayClientCredentialChains.get(keyArg) || Promise.resolve();
1472
+ const run = previous.catch(() => undefined).then(taskArg);
1473
+ const settled = run.then(() => undefined, () => undefined);
1474
+ this.gatewayClientCredentialChains.set(keyArg, settled);
1475
+ try {
1476
+ return await run;
1477
+ } finally {
1478
+ if (this.gatewayClientCredentialChains.get(keyArg) === settled) {
1479
+ this.gatewayClientCredentialChains.delete(keyArg);
1480
+ }
1481
+ }
1482
+ }
1483
+
1484
+ private async withRouteSyncLock<T>(keyArg: string, taskArg: () => Promise<T>): Promise<T> {
1485
+ const previous = this.routeSyncChains.get(keyArg) || Promise.resolve();
1486
+ const run = previous
1487
+ .catch(() => undefined)
1488
+ .then(taskArg);
1489
+ const settled = run.then(
1490
+ () => undefined,
1491
+ () => undefined,
1492
+ );
1493
+ this.routeSyncChains.set(keyArg, settled);
1494
+ try {
1495
+ return await run;
1496
+ } finally {
1497
+ if (this.routeSyncChains.get(keyArg) === settled) {
1498
+ this.routeSyncChains.delete(keyArg);
1499
+ }
1500
+ }
990
1501
  }
991
1502
 
992
1503
  private buildGatewayClientExternalKey(ownership: TResolvedGatewayClientOwnership): string {
993
- const hostname = ownership.hostname?.trim().toLowerCase();
1504
+ const hostname = ownership.hostname ? normalizeGatewayHostname(ownership.hostname) : undefined;
994
1505
  const routeRef = ownership.routeRef?.trim();
995
1506
  let routeKey: string;
996
1507
  if (hostname && routeRef) {
@@ -1004,7 +1515,7 @@ export class GatewayClientHandler {
1004
1515
  return [
1005
1516
  ownership.gatewayClientType,
1006
1517
  ownership.gatewayClientId,
1007
- ownership.appId,
1518
+ ownership.appId.trim(),
1008
1519
  routeKey,
1009
1520
  ].map((part) => part.trim()).join(':');
1010
1521
  }
@@ -1038,6 +1549,148 @@ export class GatewayClientHandler {
1038
1549
  }
1039
1550
  }
1040
1551
 
1552
+ private validateGatewayRouteConfig(
1553
+ routeArg: unknown,
1554
+ ): plugins.servezoneInterfaces.data.IGatewayRouteConfig {
1555
+ const fail = (messageArg: string): never => {
1556
+ throw new plugins.typedrequest.TypedResponseError(`invalid gateway route: ${messageArg}`);
1557
+ };
1558
+ const requireObject = (valueArg: unknown, labelArg: string): Record<string, unknown> => {
1559
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
1560
+ return fail(`${labelArg} must be an object`);
1561
+ }
1562
+ const prototype = Object.getPrototypeOf(valueArg);
1563
+ if (prototype !== Object.prototype && prototype !== null) {
1564
+ return fail(`${labelArg} must be a plain object`);
1565
+ }
1566
+ return valueArg as Record<string, unknown>;
1567
+ };
1568
+ const requireOnlyKeys = (
1569
+ valueArg: Record<string, unknown>,
1570
+ allowedArg: readonly string[],
1571
+ labelArg: string,
1572
+ ): void => {
1573
+ const unknownKeys = Object.keys(valueArg).filter((keyArg) => !allowedArg.includes(keyArg));
1574
+ if (unknownKeys.length > 0) fail(`${labelArg} contains unsupported field(s): ${unknownKeys.join(', ')}`);
1575
+ };
1576
+ const requireString = (valueArg: unknown, labelArg: string): string => {
1577
+ if (typeof valueArg !== 'string' || !valueArg.trim()) return fail(`${labelArg} must be a non-empty string`);
1578
+ return valueArg.trim();
1579
+ };
1580
+ const requireBoolean = (valueArg: unknown, labelArg: string): boolean => {
1581
+ if (typeof valueArg !== 'boolean') return fail(`${labelArg} must be a boolean`);
1582
+ return valueArg;
1583
+ };
1584
+ const requirePort = (valueArg: unknown, labelArg: string): number => {
1585
+ if (!Number.isSafeInteger(valueArg) || Number(valueArg) < 1 || Number(valueArg) > 65535) {
1586
+ return fail(`${labelArg} must be an integer from 1 through 65535`);
1587
+ }
1588
+ return Number(valueArg);
1589
+ };
1590
+
1591
+ const route = requireObject(routeArg, 'route');
1592
+ requireOnlyKeys(route, ['name', 'match', 'action', 'priority', 'managedRouteKind', 'remoteIngress'], 'route');
1593
+ const name = requireString(route.name, 'route.name');
1594
+
1595
+ const match = requireObject(route.match, 'route.match');
1596
+ requireOnlyKeys(match, ['ports', 'domains', 'transport', 'path'], 'route.match');
1597
+ const portValues = match.ports;
1598
+ if (!Array.isArray(portValues) || portValues.length === 0) {
1599
+ fail('route.match.ports must be a non-empty array');
1600
+ }
1601
+ const ports = (portValues as unknown[]).map((portArg, indexArg) => requirePort(portArg, `route.match.ports[${indexArg}]`));
1602
+ let domains: string[] | undefined;
1603
+ if (match.domains !== undefined) {
1604
+ const domainValues = match.domains;
1605
+ if (!Array.isArray(domainValues) || domainValues.length === 0) {
1606
+ fail('route.match.domains must be a non-empty array when present');
1607
+ }
1608
+ domains = (domainValues as unknown[]).map((domainArg, indexArg) => {
1609
+ return normalizeGatewayHostname(requireString(domainArg, `route.match.domains[${indexArg}]`));
1610
+ });
1611
+ }
1612
+ let transport: 'tcp' | 'udp' | 'all' | undefined;
1613
+ if (match.transport !== undefined) {
1614
+ if (match.transport !== 'tcp' && match.transport !== 'udp' && match.transport !== 'all') {
1615
+ fail('route.match.transport is unsupported');
1616
+ }
1617
+ transport = match.transport as 'tcp' | 'udp' | 'all';
1618
+ }
1619
+ const path = match.path === undefined ? undefined : requireString(match.path, 'route.match.path');
1620
+
1621
+ const action = requireObject(route.action, 'route.action');
1622
+ requireOnlyKeys(action, ['type', 'targets', 'tls', 'websocket'], 'route.action');
1623
+ if (action.type !== 'forward') fail('route.action.type must be forward');
1624
+ const targetValues = action.targets;
1625
+ if (!Array.isArray(targetValues) || targetValues.length === 0) {
1626
+ fail('route.action.targets must be a non-empty array');
1627
+ }
1628
+ const targets = (targetValues as unknown[]).map((targetArg, indexArg) => {
1629
+ const target = requireObject(targetArg, `route.action.targets[${indexArg}]`);
1630
+ requireOnlyKeys(target, ['host', 'port'], `route.action.targets[${indexArg}]`);
1631
+ return {
1632
+ host: normalizeGatewayTargetHost(requireString(target.host, `route.action.targets[${indexArg}].host`)),
1633
+ port: requirePort(target.port, `route.action.targets[${indexArg}].port`),
1634
+ };
1635
+ });
1636
+ let tls: plugins.servezoneInterfaces.data.IGatewayRouteTls | undefined;
1637
+ if (action.tls !== undefined) {
1638
+ const tlsValue = requireObject(action.tls, 'route.action.tls');
1639
+ requireOnlyKeys(tlsValue, ['mode', 'certificate'], 'route.action.tls');
1640
+ if (tlsValue.mode !== 'terminate' && tlsValue.mode !== 'passthrough'
1641
+ && tlsValue.mode !== 'terminate-and-reencrypt') fail('route.action.tls.mode is unsupported');
1642
+ if (tlsValue.certificate !== undefined && tlsValue.certificate !== 'auto') {
1643
+ fail('route.action.tls.certificate must be auto when present');
1644
+ }
1645
+ tls = {
1646
+ mode: tlsValue.mode as 'terminate' | 'passthrough' | 'terminate-and-reencrypt',
1647
+ ...(tlsValue.certificate === 'auto' ? { certificate: 'auto' as const } : {}),
1648
+ };
1649
+ }
1650
+ let websocket: { enabled: boolean } | undefined;
1651
+ if (action.websocket !== undefined) {
1652
+ const websocketValue = requireObject(action.websocket, 'route.action.websocket');
1653
+ requireOnlyKeys(websocketValue, ['enabled'], 'route.action.websocket');
1654
+ websocket = { enabled: requireBoolean(websocketValue.enabled, 'route.action.websocket.enabled') };
1655
+ }
1656
+ let priority: number | undefined;
1657
+ if (route.priority !== undefined) {
1658
+ if (!Number.isSafeInteger(route.priority)) fail('route.priority must be a safe integer');
1659
+ priority = Number(route.priority);
1660
+ }
1661
+ let managedRouteKind: 'letsencrypt-http01-forward' | undefined;
1662
+ if (route.managedRouteKind !== undefined) {
1663
+ if (route.managedRouteKind !== 'letsencrypt-http01-forward') fail('route.managedRouteKind is unsupported');
1664
+ managedRouteKind = route.managedRouteKind as 'letsencrypt-http01-forward';
1665
+ }
1666
+ let remoteIngress: { enabled: boolean; edgeFilter?: string[] } | undefined;
1667
+ if (route.remoteIngress !== undefined) {
1668
+ const remoteValue = requireObject(route.remoteIngress, 'route.remoteIngress');
1669
+ requireOnlyKeys(remoteValue, ['enabled', 'edgeFilter'], 'route.remoteIngress');
1670
+ let edgeFilter: string[] | undefined;
1671
+ if (remoteValue.edgeFilter !== undefined) {
1672
+ const edgeFilterValues = remoteValue.edgeFilter;
1673
+ if (!Array.isArray(edgeFilterValues)) fail('route.remoteIngress.edgeFilter must be an array');
1674
+ edgeFilter = (edgeFilterValues as unknown[]).map((entryArg, indexArg) => {
1675
+ return requireString(entryArg, `route.remoteIngress.edgeFilter[${indexArg}]`);
1676
+ });
1677
+ }
1678
+ remoteIngress = {
1679
+ enabled: requireBoolean(remoteValue.enabled, 'route.remoteIngress.enabled'),
1680
+ ...(edgeFilter ? { edgeFilter } : {}),
1681
+ };
1682
+ }
1683
+
1684
+ return {
1685
+ name,
1686
+ match: { ports, ...(domains ? { domains } : {}), ...(transport ? { transport } : {}), ...(path ? { path } : {}) },
1687
+ action: { type: 'forward', targets, ...(tls ? { tls } : {}), ...(websocket ? { websocket } : {}) },
1688
+ ...(priority !== undefined ? { priority } : {}),
1689
+ ...(managedRouteKind ? { managedRouteKind } : {}),
1690
+ ...(remoteIngress ? { remoteIngress } : {}),
1691
+ };
1692
+ }
1693
+
1041
1694
 
1042
1695
  private normalizeGatewayClientRoute(
1043
1696
  route: interfaces.data.IDcRouterRouteConfig,
@@ -1046,6 +1699,18 @@ export class GatewayClientHandler {
1046
1699
  ): interfaces.data.IDcRouterRouteConfig {
1047
1700
  const normalizedRoute = structuredClone(route);
1048
1701
  delete normalizedRoute.security;
1702
+ normalizedRoute.match = {
1703
+ ...normalizedRoute.match,
1704
+ ...(this.getRouteHostnames(normalizedRoute).length
1705
+ ? { domains: this.getRouteHostnames(normalizedRoute) }
1706
+ : {}),
1707
+ };
1708
+ if (normalizedRoute.action?.targets) {
1709
+ normalizedRoute.action.targets = normalizedRoute.action.targets.map((targetArg) => ({
1710
+ ...targetArg,
1711
+ host: normalizeGatewayTargetHost(targetArg.host as string),
1712
+ }));
1713
+ }
1049
1714
  if (ownership.hostname && this.getRouteHostnames(normalizedRoute).length === 0) {
1050
1715
  normalizedRoute.match = {
1051
1716
  ...normalizedRoute.match,
@@ -1069,6 +1734,9 @@ export class GatewayClientHandler {
1069
1734
  throw new plugins.typedrequest.TypedResponseError('routeRef ownership cannot include domains');
1070
1735
  }
1071
1736
  const ownedHostname = ownership.hostname.trim().toLowerCase();
1737
+ if (routeDomains.length !== 1) {
1738
+ throw new plugins.typedrequest.TypedResponseError('hostname ownership requires exactly one route domain');
1739
+ }
1072
1740
  if (routeDomains.some((domainArg) => domainArg.toLowerCase() !== ownedHostname)) {
1073
1741
  throw new plugins.typedrequest.TypedResponseError('route domains must match ownership hostname');
1074
1742
  }