@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
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;
@@ -7,6 +7,7 @@ import type {
7
7
  } from '../db/documents/classes.cached.email.js';
8
8
  import type {
9
9
  Email,
10
+ IAcceptEnvelopeContext,
10
11
  IExtendedSmtpSession,
11
12
  IMessageAcceptanceContext,
12
13
  IMessageAcceptanceDecision,
@@ -20,6 +21,8 @@ const ACCEPTED_EMAIL_RETRY_DELAY_MS = 5 * 60_000;
20
21
  const ACCEPTED_EMAIL_QUEUE_LEASE_MS = 30 * 60_000;
21
22
  const ACCEPTED_EMAIL_SPOOL_BATCH_SIZE = 25;
22
23
  const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;
24
+ /** Retention for catch-all stored inbound mail (30 days). */
25
+ const INBOUND_STORE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
23
26
 
24
27
  export type TSmartMtaQueueItemLike = {
25
28
  id?: string;
@@ -252,6 +255,114 @@ export class AcceptedEmailSpool {
252
255
  };
253
256
  }
254
257
 
258
+ /**
259
+ * Durable acceptance for envelopes containing at least one catch-all store
260
+ * route: anchors the exact raw message + metadata as a CachedEmail before
261
+ * SMTP 250, then dispatches any non-store recipients through SmartMTA's
262
+ * exact-plan executor. Store recipients are fulfilled by the anchor itself.
263
+ */
264
+ public async acceptEnvelope(
265
+ context: IAcceptEnvelopeContext,
266
+ emailServer: UnifiedEmailServer,
267
+ ): Promise<void> {
268
+ if (!this.dcRouterRef.dcRouterDb?.isReady()) {
269
+ throw new Error('DcRouterDb is not available for durable envelope acceptance');
270
+ }
271
+ this.throwIfMessageAcceptanceAborted(context.abortSignal);
272
+
273
+ const email = context.email;
274
+ const session = context.session;
275
+ const cachedEmail = CachedEmail.createNew();
276
+ this.removeHeader(email.headers, DCROUTER_CACHE_ID_HEADER);
277
+ email.headers[DCROUTER_CACHE_ID_HEADER] = cachedEmail.id;
278
+ cachedEmail.messageId = email.headers['Message-ID'] || email.headers['message-id'] || cachedEmail.id;
279
+ cachedEmail.from = context.envelope.mailFrom || email.from || '';
280
+ cachedEmail.to = context.envelope.rcptTo.length > 0
281
+ ? [...context.envelope.rcptTo]
282
+ : Array.isArray(email.to) ? email.to : [];
283
+ cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
284
+ cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
285
+ cachedEmail.subject = email.subject || '';
286
+ cachedEmail.attachmentCount = context.attachmentCount;
287
+ if (context.securityResults) {
288
+ cachedEmail.inboundSecurityResults = JSON.stringify(context.securityResults);
289
+ }
290
+
291
+ // Build the exact per-recipient dispatch plan before durably committing,
292
+ // so an unplannable envelope is refused instead of half-anchored.
293
+ const idempotencyKeys = context.resolvedRecipientRoutes.map(
294
+ (resolution) => `${cachedEmail.id}:${resolution.recipient}`,
295
+ );
296
+ const plan = emailServer.createAcceptedEnvelopeDispatchPlan(context, idempotencyKeys);
297
+ const nonStoreEntries = plan.filter((entry) => entry.action.type !== 'store');
298
+
299
+ const persistedRawMessage = this.setDcRouterCacheIdHeader(
300
+ context.rawMessage.toString('utf8'),
301
+ cachedEmail.id,
302
+ );
303
+ await this.persistRawMessage(cachedEmail, persistedRawMessage);
304
+ cachedEmail.direction = session.authenticated ? 'outbound' : 'inbound';
305
+ cachedEmail.acceptedAt = Date.now();
306
+ cachedEmail.markStored(INBOUND_STORE_RETENTION_MS);
307
+ cachedEmail.routeData = JSON.stringify({
308
+ acceptedAt: new Date().toISOString(),
309
+ acceptance: 'durable-envelope',
310
+ recipientPlans: plan.map((entry) => ({
311
+ recipient: entry.recipient,
312
+ routeName: entry.routeName,
313
+ source: entry.source,
314
+ actionType: entry.action.type,
315
+ })),
316
+ session: {
317
+ id: session.id,
318
+ remoteAddress: session.remoteAddress,
319
+ clientHostname: session.clientHostname,
320
+ secure: !!session.secure,
321
+ authenticated: !!session.authenticated,
322
+ user: session.user,
323
+ envelope: context.envelope,
324
+ },
325
+ });
326
+ cachedEmail.updateSenderDomain();
327
+ cachedEmail.updateRecipientDomains();
328
+ try {
329
+ await cachedEmail.save();
330
+ await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-stored');
331
+ } catch (error) {
332
+ await this.deleteRawMessage(cachedEmail).catch(() => undefined);
333
+ throw error;
334
+ }
335
+ if (context.abortSignal?.aborted) {
336
+ cachedEmail.markFailed('Envelope acceptance aborted before SMTP success');
337
+ await cachedEmail.save();
338
+ await this.notifyEmailQueuePersisted(cachedEmail, 'envelope-acceptance-aborted');
339
+ throw new Error('Envelope acceptance aborted before SMTP success');
340
+ }
341
+
342
+ if (nonStoreEntries.length > 0) {
343
+ const dispatchResult = await emailServer.dispatchAcceptedEnvelope(
344
+ context.rawMessage,
345
+ plan,
346
+ {
347
+ mailFrom: context.envelope.mailFrom,
348
+ session: {
349
+ id: session.id,
350
+ remoteAddress: session.remoteAddress,
351
+ clientHostname: session.clientHostname,
352
+ secure: !!session.secure,
353
+ authenticated: !!session.authenticated,
354
+ },
355
+ },
356
+ );
357
+ const failed = dispatchResult.results.filter(
358
+ (result) => result.status === 'failed' || result.status === 'rejected',
359
+ );
360
+ if (failed.length > 0) {
361
+ logger.log('warn', `Durable envelope ${cachedEmail.id}: ${failed.length}/${dispatchResult.results.length} non-store recipients failed dispatch: ${failed.map((result) => `${result.recipient}=${result.message || result.status}`).join('; ')}`);
362
+ }
363
+ }
364
+ }
365
+
255
366
  /** Start the interval-driven spool processor and trigger an immediate run. */
256
367
  public start(): void {
257
368
  this.clearSpoolTimer();
@@ -1,5 +1,9 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { IStorageManager } from '@push.rocks/smartmta';
2
+ import type {
3
+ IStorageManager,
4
+ IStorageListPageOptions,
5
+ IStorageListPageResult,
6
+ } from '@push.rocks/smartmta';
3
7
  import {
4
8
  getSmartMtaTextNamespace,
5
9
  hashSmartMtaStorageKey,
@@ -98,6 +102,58 @@ export class SmartMtaStorageManager implements IStorageManager {
98
102
  .sort();
99
103
  }
100
104
 
105
+ /**
106
+ * Bounded, deletion-stable page listing: seeks strictly beyond the last
107
+ * returned key, so tokens stay valid while pages are deleted and an
108
+ * otherwise stable prefix is always exhausted.
109
+ */
110
+ public async listPage(
111
+ prefix: string,
112
+ options: IStorageListPageOptions,
113
+ ): Promise<IStorageListPageResult> {
114
+ const normalizedPrefix = normalizeSmartMtaStorageKey(prefix);
115
+ getSmartMtaTextNamespace(normalizedPrefix);
116
+ const requestedLimit = Number.isSafeInteger(options?.limit) && options.limit > 0
117
+ ? options.limit
118
+ : 100;
119
+ const limit = Math.min(requestedLimit, 1000);
120
+ let seekAfter: string | undefined;
121
+ if (options?.continuationToken) {
122
+ const decoded = Buffer.from(options.continuationToken, 'base64url').toString('utf8');
123
+ if (!decoded.startsWith(normalizedPrefix)) {
124
+ throw new Error('SmartMTA storage continuation token does not match the requested prefix');
125
+ }
126
+ seekAfter = decoded;
127
+ }
128
+
129
+ const documents = await this.collection
130
+ .find({
131
+ key: {
132
+ ...(seekAfter ? { $gt: seekAfter } : { $gte: normalizedPrefix }),
133
+ $lt: `${normalizedPrefix}￿`,
134
+ },
135
+ })
136
+ .sort({ key: 1 })
137
+ .limit(limit + 1)
138
+ .toArray();
139
+
140
+ const hasMore = documents.length > limit;
141
+ const pageDocuments = documents.slice(0, limit);
142
+ const keys = pageDocuments
143
+ .map((document: ISmartMtaStorageDocument) => this.validateDocument(document).key)
144
+ .filter((key: string) => key.startsWith(normalizedPrefix));
145
+ const lastKey = pageDocuments.length > 0
146
+ ? (pageDocuments[pageDocuments.length - 1] as ISmartMtaStorageDocument).key
147
+ : undefined;
148
+
149
+ return {
150
+ keys,
151
+ ...(hasMore && lastKey
152
+ ? { continuationToken: Buffer.from(lastKey, 'utf8').toString('base64url') }
153
+ : {}),
154
+ };
155
+ }
156
+
101
157
  public async delete(key: string): Promise<void> {
102
158
  const normalizedKey = normalizeSmartMtaStorageKey(key);
103
159
  getSmartMtaTextNamespace(normalizedKey);
@@ -11,6 +11,8 @@ export const smartMtaTextNamespaces: readonly ISmartMtaTextNamespace[] = Object.
11
11
  { prefix: '/email/bounces/', maxBytes: 8 * 1024 * 1024 },
12
12
  { prefix: '/email/templates/', maxBytes: 4 * 1024 * 1024 },
13
13
  { prefix: '/email/routes/config.json', maxBytes: 4 * 1024 * 1024 },
14
+ // SmartMTA 8.1+ accepted-envelope dispatch bookkeeping (idempotent replay records).
15
+ { prefix: '/email/accepted-envelope-dispatch/', maxBytes: 64 * 1024 },
14
16
  { prefix: '/security/ip-reputation-cache.json', maxBytes: 8 * 1024 * 1024 },
15
17
  { prefix: '/workhosters/mail-identities.json', maxBytes: 4 * 1024 * 1024 },
16
18
  ]);
@@ -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
  }