@serve.zone/dcrouter 17.2.3 → 17.3.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 (98) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +2219 -1887
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +4 -1
  5. package/dist_ts/classes.dcrouter.js +42 -2
  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.gateway-client.doc.d.ts +25 -0
  13. package/dist_ts/db/documents/classes.gateway-client.doc.js +73 -2
  14. package/dist_ts/db/documents/classes.ops-config-event.doc.d.ts +55 -0
  15. package/dist_ts/db/documents/classes.ops-config-event.doc.js +296 -0
  16. package/dist_ts/db/documents/index.d.ts +1 -0
  17. package/dist_ts/db/documents/index.js +2 -1
  18. package/dist_ts/dns/classes.gateway-route-dns-reconciler.d.ts +52 -0
  19. package/dist_ts/dns/classes.gateway-route-dns-reconciler.js +609 -0
  20. package/dist_ts/dns/index.d.ts +1 -0
  21. package/dist_ts/dns/index.js +2 -1
  22. package/dist_ts/dns/manager.dns.d.ts +19 -0
  23. package/dist_ts/dns/manager.dns.js +25 -1
  24. package/dist_ts/monitoring/classes.ops-event-manager.d.ts +32 -0
  25. package/dist_ts/monitoring/classes.ops-event-manager.js +71 -0
  26. package/dist_ts/monitoring/index.d.ts +1 -0
  27. package/dist_ts/monitoring/index.js +2 -1
  28. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  29. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  30. package/dist_ts/opsserver/handlers/certificate.handler.d.ts +1 -0
  31. package/dist_ts/opsserver/handlers/certificate.handler.js +35 -5
  32. package/dist_ts/opsserver/handlers/config-event.handler.d.ts +12 -0
  33. package/dist_ts/opsserver/handlers/config-event.handler.js +47 -0
  34. package/dist_ts/opsserver/handlers/dns-record.handler.d.ts +1 -0
  35. package/dist_ts/opsserver/handlers/dns-record.handler.js +82 -3
  36. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +9 -1
  37. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +714 -162
  38. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  39. package/dist_ts/opsserver/handlers/index.js +2 -1
  40. package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +25 -0
  41. package/dist_ts/opsserver/helpers/gateway-client-auth.js +168 -0
  42. package/dist_ts/plugins.d.ts +2 -1
  43. package/dist_ts/plugins.js +3 -2
  44. package/dist_ts_apiclient/classes.gatewayclient.d.ts +13 -1
  45. package/dist_ts_apiclient/classes.gatewayclient.js +18 -2
  46. package/dist_ts_interfaces/data/config-events.d.ts +34 -0
  47. package/dist_ts_interfaces/data/config-events.js +8 -0
  48. package/dist_ts_interfaces/data/index.d.ts +1 -0
  49. package/dist_ts_interfaces/data/index.js +2 -1
  50. package/dist_ts_interfaces/data/route-management.d.ts +28 -1
  51. package/dist_ts_interfaces/data/route-management.js +3 -1
  52. package/dist_ts_interfaces/data/workhoster.d.ts +1 -0
  53. package/dist_ts_interfaces/requests/config-events.d.ts +35 -0
  54. package/dist_ts_interfaces/requests/config-events.js +4 -0
  55. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  56. package/dist_ts_interfaces/requests/index.js +2 -1
  57. package/dist_ts_interfaces/requests/realtime.d.ts +1 -1
  58. package/dist_ts_interfaces/requests/realtime.js +2 -1
  59. package/dist_ts_migrations/index.js +148 -9
  60. package/dist_ts_web/00_commitinfo_data.js +1 -1
  61. package/dist_ts_web/appstate/config-events.d.ts +19 -0
  62. package/dist_ts_web/appstate/config-events.js +68 -0
  63. package/dist_ts_web/appstate/runtime.js +7 -1
  64. package/dist_ts_web/appstate.d.ts +1 -0
  65. package/dist_ts_web/appstate.js +2 -1
  66. package/dist_ts_web/elements/ops-platform-events.d.ts +21 -0
  67. package/dist_ts_web/elements/ops-platform-events.js +361 -0
  68. package/dist_ts_web/elements/ops-view-logs.d.ts +4 -0
  69. package/dist_ts_web/elements/ops-view-logs.js +96 -9
  70. package/package.json +3 -3
  71. package/ts/00_commitinfo_data.ts +1 -1
  72. package/ts/classes.dcrouter.ts +48 -1
  73. package/ts/config/classes.api-token-manager.ts +237 -4
  74. package/ts/config/classes.gateway-client-manager.ts +463 -19
  75. package/ts/db/documents/classes.api-token.doc.ts +8 -1
  76. package/ts/db/documents/classes.gateway-client.doc.ts +86 -0
  77. package/ts/db/documents/classes.ops-config-event.doc.ts +257 -0
  78. package/ts/db/documents/index.ts +1 -0
  79. package/ts/dns/classes.gateway-route-dns-reconciler.ts +801 -0
  80. package/ts/dns/index.ts +1 -0
  81. package/ts/dns/manager.dns.ts +36 -0
  82. package/ts/monitoring/classes.ops-event-manager.ts +99 -0
  83. package/ts/monitoring/index.ts +2 -1
  84. package/ts/opsserver/classes.opsserver.ts +2 -0
  85. package/ts/opsserver/handlers/certificate.handler.ts +51 -4
  86. package/ts/opsserver/handlers/config-event.handler.ts +62 -0
  87. package/ts/opsserver/handlers/dns-record.handler.ts +101 -2
  88. package/ts/opsserver/handlers/gatewayclient.handler.ts +804 -136
  89. package/ts/opsserver/handlers/index.ts +1 -0
  90. package/ts/opsserver/helpers/gateway-client-auth.ts +198 -0
  91. package/ts/plugins.ts +2 -0
  92. package/ts_apiclient/classes.gatewayclient.ts +45 -0
  93. package/ts_web/00_commitinfo_data.ts +1 -1
  94. package/ts_web/appstate/config-events.ts +98 -0
  95. package/ts_web/appstate/runtime.ts +8 -1
  96. package/ts_web/appstate.ts +1 -0
  97. package/ts_web/elements/ops-platform-events.ts +315 -0
  98. package/ts_web/elements/ops-view-logs.ts +84 -7
package/ts/dns/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './manager.dns.js';
2
2
  export * from './providers/index.js';
3
3
  export * from './classes.dns-server-runtime.js';
4
+ export * from './classes.gateway-route-dns-reconciler.js';
4
5
  export * from './txt-record-presentation.js';
@@ -961,6 +961,42 @@ export class DnsManager {
961
961
  return { success: true, id: doc.id };
962
962
  }
963
963
 
964
+ /**
965
+ * Adopt an already-correct record into an automation lifecycle without
966
+ * mutating provider DNS. The expected value fields make adoption fail closed
967
+ * if the provider mirror changed between inspection and persistence.
968
+ */
969
+ public async adoptRecordManagement(args: {
970
+ id: string;
971
+ name: string;
972
+ type: TDnsRecordType;
973
+ value: string;
974
+ ttl: number;
975
+ proxied: boolean;
976
+ managedBy: string;
977
+ managedOwnerId: string;
978
+ managedRecordKey: string;
979
+ }): Promise<{ success: boolean; message?: string }> {
980
+ const doc = await DnsRecordDoc.findById(args.id);
981
+ if (!doc) return { success: false, message: 'Record not found' };
982
+ if (
983
+ doc.name.toLowerCase().replace(/\.+$/, '') !== args.name.toLowerCase().replace(/\.+$/, '')
984
+ || doc.type !== args.type
985
+ || doc.value !== args.value
986
+ || doc.ttl !== args.ttl
987
+ || doc.proxied !== args.proxied
988
+ || doc.managedBy
989
+ ) {
990
+ return { success: false, message: 'DNS record changed before provenance adoption' };
991
+ }
992
+ doc.managedBy = args.managedBy;
993
+ doc.managedOwnerId = args.managedOwnerId;
994
+ doc.managedRecordKey = args.managedRecordKey;
995
+ doc.updatedAt = Date.now();
996
+ await doc.save();
997
+ return { success: true };
998
+ }
999
+
964
1000
  public async updateRecord(args: {
965
1001
  id: string;
966
1002
  name?: string;
@@ -0,0 +1,99 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { logger } from '../logger.js';
3
+ import {
4
+ OpsConfigEventDoc,
5
+ type IOpsConfigEventFilter,
6
+ } from '../db/documents/classes.ops-config-event.doc.js';
7
+ import type {
8
+ IOpsConfigEvent,
9
+ IOpsConfigEventCursor,
10
+ IOpsConfigEventContext,
11
+ TOpsConfigEventSeverity,
12
+ } from '../../ts_interfaces/data/config-events.js';
13
+
14
+ export interface IRecordOpsConfigEventOptions {
15
+ severity: TOpsConfigEventSeverity;
16
+ category: string;
17
+ title: string;
18
+ detail: string;
19
+ context?: IOpsConfigEventContext;
20
+ }
21
+
22
+ /** Retention for acknowledged events (90 days). */
23
+ const ACKNOWLEDGED_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
24
+ const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
25
+
26
+ /**
27
+ * OpsEventManager — durable, acknowledgeable platform configuration events.
28
+ *
29
+ * These are operator-facing config conflicts (e.g. gateway DNS automation
30
+ * overwriting a manual record), not application runtime errors. Producers call
31
+ * `recordEvent()` fire-and-forget; the ops UI lists and acknowledges them.
32
+ *
33
+ * Repeated occurrences of the same unacknowledged conflict coalesce into one
34
+ * event (refreshed timestamp/detail) so reconcile loops cannot flood the inbox.
35
+ */
36
+ export class OpsEventManager {
37
+ private pruneInterval: ReturnType<typeof setInterval> | null = null;
38
+
39
+ public async start(): Promise<void> {
40
+ this.pruneInterval = setInterval(() => {
41
+ OpsConfigEventDoc.pruneAcknowledged(ACKNOWLEDGED_RETENTION_MS).catch((error: unknown) => {
42
+ logger.log('warn', `OpsEventManager prune failed: ${(error as Error).message}`);
43
+ });
44
+ }, PRUNE_INTERVAL_MS);
45
+ // Prune once at startup so long-stopped instances converge immediately.
46
+ await OpsConfigEventDoc.pruneAcknowledged(ACKNOWLEDGED_RETENTION_MS).catch(() => 0);
47
+ }
48
+
49
+ public async stop(): Promise<void> {
50
+ if (this.pruneInterval) {
51
+ clearInterval(this.pruneInterval);
52
+ this.pruneInterval = null;
53
+ }
54
+ }
55
+
56
+ public async recordEvent(options: IRecordOpsConfigEventOptions): Promise<IOpsConfigEvent> {
57
+ const context = options.context || {};
58
+ const sourceKey = [
59
+ options.title,
60
+ context.recordName || '',
61
+ context.domain || '',
62
+ context.routeRef || '',
63
+ ].join('|');
64
+ const openDedupeKey = plugins.crypto
65
+ .createHash('sha256')
66
+ .update(sourceKey)
67
+ .digest('hex');
68
+ const now = Date.now();
69
+ const { event, created } = await OpsConfigEventDoc.upsertOpenEvent({
70
+ id: `oce_${now.toString(36)}_${Math.random().toString(36).slice(2, 10)}`,
71
+ openDedupeKey,
72
+ severity: options.severity,
73
+ category: options.category,
74
+ title: options.title,
75
+ detail: options.detail,
76
+ context,
77
+ createdAt: now,
78
+ });
79
+ if (created) {
80
+ logger.log(
81
+ options.severity === 'error' ? 'error' : 'warn',
82
+ `Platform config event [${options.category}] ${options.title}`,
83
+ );
84
+ }
85
+ return event.toApiObject();
86
+ }
87
+
88
+ public async listEvents(
89
+ filter: IOpsConfigEventFilter = {},
90
+ ): Promise<{ events: IOpsConfigEvent[]; total: number; unacknowledgedCount: number; nextCursor?: IOpsConfigEventCursor }> {
91
+ const { events, total, nextCursor } = await OpsConfigEventDoc.findFiltered(filter);
92
+ const unacknowledgedCount = await OpsConfigEventDoc.countUnacknowledged();
93
+ return { events: events.map((doc) => doc.toApiObject()), total, unacknowledgedCount, nextCursor };
94
+ }
95
+
96
+ public async acknowledgeEvents(ids: string[], userId: string): Promise<number> {
97
+ return OpsConfigEventDoc.acknowledgeByIds(ids, userId);
98
+ }
99
+ }
@@ -1 +1,2 @@
1
- export * from './classes.metricsmanager.js';
1
+ export * from './classes.metricsmanager.js';
2
+ export * from './classes.ops-event-manager.js';
@@ -41,6 +41,7 @@ export class OpsServer {
41
41
  private acmeConfigHandler!: handlers.AcmeConfigHandler;
42
42
  private emailDomainHandler!: handlers.EmailDomainHandler;
43
43
  private gatewayClientHandler!: handlers.GatewayClientHandler;
44
+ private configEventHandler!: handlers.ConfigEventHandler;
44
45
  private smtpRealtimeUnsubscribe?: () => void;
45
46
  private emailQueueRealtimeUnsubscribe?: () => void;
46
47
 
@@ -105,6 +106,7 @@ export class OpsServer {
105
106
  this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
106
107
  this.emailDomainHandler = new handlers.EmailDomainHandler(this);
107
108
  this.gatewayClientHandler = new handlers.GatewayClientHandler(this);
109
+ this.configEventHandler = new handlers.ConfigEventHandler(this);
108
110
  this.smtpRealtimeUnsubscribe =
109
111
  this.dcRouterRef.acceptedEmailSpool?.onSmtpTransactionPersisted?.((eventArg) => {
110
112
  this.invalidateRealtime(['smtpAttempts', 'emailQueue'], {
@@ -4,6 +4,12 @@ import * as interfaces from '../../../ts_interfaces/index.js';
4
4
  import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
5
5
  import { logger } from '../../logger.js';
6
6
  import { requireOpsAuth } from '../helpers/auth.js';
7
+ import {
8
+ hasOwnedEnabledGatewayRoute,
9
+ matchesGatewayHostnamePatterns,
10
+ normalizeGatewayHostname,
11
+ requireGatewayMachineAuth,
12
+ } from '../helpers/gateway-client-auth.js';
7
13
 
8
14
  /**
9
15
  * Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
@@ -45,6 +51,40 @@ export class CertificateHandler {
45
51
  return auth.userId;
46
52
  }
47
53
 
54
+ private async requireCertificateAuth(
55
+ requestArg: { identity?: interfaces.data.IIdentity; apiToken?: string },
56
+ domainArg: string,
57
+ capabilityArg: 'readCertificates' | 'requestCertificates',
58
+ ): Promise<{ userId: string; domain: string }> {
59
+ const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
60
+ const candidate = requestArg.apiToken && tokenManager
61
+ ? await tokenManager.validateToken(requestArg.apiToken)
62
+ : null;
63
+ if (candidate?.policy?.role === 'gatewayClient') {
64
+ const auth = await requireGatewayMachineAuth(
65
+ this.opsServerRef,
66
+ requestArg,
67
+ capabilityArg === 'readCertificates' ? 'gateway-clients:read' : 'gateway-clients:write',
68
+ );
69
+ if (auth.policy?.capabilities?.[capabilityArg] !== true) {
70
+ throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capabilityArg}`);
71
+ }
72
+ const domain = normalizeGatewayHostname(domainArg);
73
+ if (!matchesGatewayHostnamePatterns(domain, auth.policy.hostnamePatterns || [], auth.managedZoneNames)) {
74
+ throw new plugins.typedrequest.TypedResponseError('certificate hostname is outside gateway-client policy');
75
+ }
76
+ if (!hasOwnedEnabledGatewayRoute(this.opsServerRef, auth, domain)) {
77
+ throw new plugins.typedrequest.TypedResponseError('certificate hostname has no owned enabled gateway route');
78
+ }
79
+ return { userId: auth.userId, domain };
80
+ }
81
+ const userId = await this.requireAuth(
82
+ requestArg,
83
+ capabilityArg === 'readCertificates' ? 'certificates:read' : 'certificates:write',
84
+ );
85
+ return { userId, domain: normalizeGatewayHostname(domainArg) };
86
+ }
87
+
48
88
  private registerHandlers(): void {
49
89
  const router = this.typedrouter;
50
90
 
@@ -66,8 +106,8 @@ export class CertificateHandler {
66
106
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
67
107
  'reprovisionCertificateDomain',
68
108
  async (dataArg) => {
69
- await this.requireAuth(dataArg, 'certificates:write');
70
- const result = await this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
109
+ const auth = await this.requireCertificateAuth(dataArg, dataArg.domain, 'requestCertificates');
110
+ const result = await this.reprovisionCertificateDomain(auth.domain, dataArg.forceRenew);
71
111
  if (result.success) {
72
112
  this.opsServerRef.invalidateRealtime?.('certificates', {
73
113
  changedIds: [dataArg.domain],
@@ -102,8 +142,8 @@ export class CertificateHandler {
102
142
  new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_ExportCertificate>(
103
143
  'exportCertificate',
104
144
  async (dataArg) => {
105
- await this.requireAuth(dataArg, 'certificates:read');
106
- return this.exportCertificate(dataArg.domain);
145
+ const auth = await this.requireCertificateAuth(dataArg, dataArg.domain, 'readCertificates');
146
+ return this.exportCertificate(auth.domain);
107
147
  }
108
148
  )
109
149
  );
@@ -113,6 +153,13 @@ export class CertificateHandler {
113
153
  new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.gateway.IReq_ImportCertificate>(
114
154
  'importCertificate',
115
155
  async (dataArg) => {
156
+ const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
157
+ const candidate = dataArg.apiToken && tokenManager
158
+ ? await tokenManager.validateToken(dataArg.apiToken)
159
+ : null;
160
+ if (candidate?.policy?.role === 'gatewayClient') {
161
+ throw new plugins.typedrequest.TypedResponseError('gateway-client credentials cannot import certificates');
162
+ }
116
163
  await this.requireAuth(dataArg, 'certificates:write');
117
164
  const result = await this.importCertificate(dataArg.cert);
118
165
  if (result.success) {
@@ -0,0 +1,62 @@
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
+ import { requireOpsAuth } from '../helpers/auth.js';
5
+
6
+ /**
7
+ * Handlers for the platform configuration event inbox (list + acknowledge).
8
+ */
9
+ export class ConfigEventHandler {
10
+ public typedrouter = new plugins.typedrequest.TypedRouter();
11
+
12
+ constructor(private opsServerRef: OpsServer) {
13
+ this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
14
+ this.registerHandlers();
15
+ }
16
+
17
+ private registerHandlers(): void {
18
+ this.typedrouter.addTypedHandler(
19
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListConfigEvents>(
20
+ 'listConfigEvents',
21
+ async (dataArg) => {
22
+ await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'config-events:read' });
23
+ const opsEventManager = this.opsServerRef.dcRouterRef.opsEventManager;
24
+ if (!opsEventManager) {
25
+ return { events: [], total: 0, unacknowledgedCount: 0, nextCursor: undefined };
26
+ }
27
+ return opsEventManager.listEvents({
28
+ acknowledged: dataArg.acknowledged,
29
+ severity: dataArg.severity,
30
+ category: dataArg.category,
31
+ limit: dataArg.limit,
32
+ cursor: dataArg.cursor,
33
+ });
34
+ },
35
+ ),
36
+ );
37
+
38
+ this.typedrouter.addTypedHandler(
39
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AcknowledgeConfigEvents>(
40
+ 'acknowledgeConfigEvents',
41
+ async (dataArg) => {
42
+ const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
43
+ scope: 'config-events:write',
44
+ requireAdminIdentity: true,
45
+ });
46
+ const opsEventManager = this.opsServerRef.dcRouterRef.opsEventManager;
47
+ if (!opsEventManager) {
48
+ return { success: false, acknowledgedCount: 0, message: 'OpsEventManager not initialized' };
49
+ }
50
+ const acknowledgedCount = await opsEventManager.acknowledgeEvents(
51
+ dataArg.ids || [],
52
+ auth.userId,
53
+ );
54
+ this.opsServerRef.invalidateRealtime?.(['configEvents'], {
55
+ reason: 'config-events-acknowledged',
56
+ });
57
+ return { success: true, acknowledgedCount };
58
+ },
59
+ ),
60
+ );
61
+ }
62
+ }
@@ -1,7 +1,10 @@
1
1
  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
+ import { logger } from '../../logger.js';
4
5
  import { requireOpsAuth } from '../helpers/auth.js';
6
+ import { normalizeGatewayHostname } from '../helpers/gateway-client-auth.js';
7
+ import { getActiveGatewayDnsClaims } from '../../dns/classes.gateway-route-dns-reconciler.js';
5
8
 
6
9
  /**
7
10
  * CRUD handlers for DnsRecordDoc.
@@ -62,6 +65,14 @@ export class DnsRecordHandler {
62
65
  const userId = await this.requireAuth(dataArg, 'dns-records:write');
63
66
  const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
64
67
  if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
68
+ const claimError = await this.rejectClaimedHostnameMutation(
69
+ dataArg.domainId,
70
+ dataArg.name,
71
+ dataArg.type,
72
+ userId,
73
+ 'create',
74
+ );
75
+ if (claimError) return { success: false, message: claimError };
65
76
  const result = await dnsManager.createRecord({
66
77
  domainId: dataArg.domainId,
67
78
  name: dataArg.name,
@@ -87,9 +98,30 @@ export class DnsRecordHandler {
87
98
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
88
99
  'updateDnsRecord',
89
100
  async (dataArg) => {
90
- await this.requireAuth(dataArg, 'dns-records:write');
101
+ const userId = await this.requireAuth(dataArg, 'dns-records:write');
91
102
  const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
92
103
  if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
104
+ const existing = await dnsManager.getRecord(dataArg.id);
105
+ if (existing) {
106
+ const currentClaimError = await this.rejectClaimedHostnameMutation(
107
+ existing.domainId,
108
+ existing.name,
109
+ existing.type,
110
+ userId,
111
+ 'update',
112
+ );
113
+ if (currentClaimError) return { success: false, message: currentClaimError };
114
+ if (dataArg.name !== undefined) {
115
+ const nextClaimError = await this.rejectClaimedHostnameMutation(
116
+ existing.domainId,
117
+ dataArg.name,
118
+ existing.type,
119
+ userId,
120
+ 'update',
121
+ );
122
+ if (nextClaimError) return { success: false, message: nextClaimError };
123
+ }
124
+ }
93
125
  const result = await dnsManager.updateRecord({
94
126
  id: dataArg.id,
95
127
  name: dataArg.name,
@@ -113,9 +145,20 @@ export class DnsRecordHandler {
113
145
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
114
146
  'deleteDnsRecord',
115
147
  async (dataArg) => {
116
- await this.requireAuth(dataArg, 'dns-records:write');
148
+ const userId = await this.requireAuth(dataArg, 'dns-records:write');
117
149
  const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
118
150
  if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
151
+ const existing = await dnsManager.getRecord(dataArg.id);
152
+ if (existing) {
153
+ const claimError = await this.rejectClaimedHostnameMutation(
154
+ existing.domainId,
155
+ existing.name,
156
+ existing.type,
157
+ userId,
158
+ 'delete',
159
+ );
160
+ if (claimError) return { success: false, message: claimError };
161
+ }
119
162
  const result = await dnsManager.deleteRecord(dataArg.id);
120
163
  if (result.success) {
121
164
  this.opsServerRef.invalidateRealtime?.(['dns', 'emailDomains'], {
@@ -128,4 +171,60 @@ export class DnsRecordHandler {
128
171
  ),
129
172
  );
130
173
  }
174
+
175
+ private async rejectClaimedHostnameMutation(
176
+ domainIdArg: string,
177
+ hostnameArg: string,
178
+ recordTypeArg: interfaces.data.TDnsRecordType,
179
+ actorArg: string,
180
+ actionArg: 'create' | 'update' | 'delete',
181
+ ): Promise<string | undefined> {
182
+ const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
183
+ if (!dnsManager) return undefined;
184
+ if (recordTypeArg !== 'A' && recordTypeArg !== 'AAAA' && recordTypeArg !== 'CNAME') return undefined;
185
+ let hostname: string;
186
+ try {
187
+ hostname = normalizeGatewayHostname(hostnameArg);
188
+ } catch {
189
+ return undefined;
190
+ }
191
+ const claims = getActiveGatewayDnsClaims(this.opsServerRef.dcRouterRef, hostname);
192
+ if (!claims.length) return undefined;
193
+ const ownerSummary = claims
194
+ .map((claimArg) => `${claimArg.gatewayClientId} (${claimArg.routeExternalKeys.join(', ')})`)
195
+ .join('; ');
196
+ const message = `DNS hostname ${hostname} is claimed by gateway client route(s) ${ownerSummary}; actor ${actorArg} cannot ${actionArg} ${recordTypeArg} records at this hostname`;
197
+ let domainName: string | undefined;
198
+ try {
199
+ domainName = (await dnsManager.getDomain(domainIdArg))?.name;
200
+ } catch (error) {
201
+ logger.log('warn', `Failed to load DNS domain context for rejected mutation on ${hostname}: ${(error as Error).message}`);
202
+ }
203
+ if (this.opsServerRef.dcRouterRef.opsEventManager) {
204
+ try {
205
+ await this.opsServerRef.dcRouterRef.opsEventManager.recordEvent({
206
+ severity: 'error',
207
+ category: 'dns-conflict',
208
+ title: 'Rejected manual DNS mutation on gateway-managed hostname',
209
+ detail: message,
210
+ context: {
211
+ domain: domainName,
212
+ recordName: hostname,
213
+ routeRef: claims.flatMap((claimArg) => claimArg.routeExternalKeys).join(', '),
214
+ actor: actorArg,
215
+ },
216
+ });
217
+ } catch (error) {
218
+ logger.log('warn', `Failed to record rejected gateway DNS mutation for ${hostname}: ${(error as Error).message}`);
219
+ }
220
+ }
221
+ try {
222
+ this.opsServerRef.invalidateRealtime?.('configEvents', {
223
+ reason: 'gateway-dns-manual-mutation-rejected',
224
+ });
225
+ } catch (error) {
226
+ logger.log('warn', `Failed to invalidate config events after rejected DNS mutation for ${hostname}: ${(error as Error).message}`);
227
+ }
228
+ return message;
229
+ }
131
230
  }