@serve.zone/dcrouter 16.4.1 → 17.0.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 (82) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +852 -852
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +5 -1
  5. package/dist_ts/classes.dcrouter.js +42 -9
  6. package/dist_ts/db/documents/classes.dns-record.doc.d.ts +4 -0
  7. package/dist_ts/db/documents/classes.dns-record.doc.js +33 -2
  8. package/dist_ts/db/documents/classes.email-domain.doc.d.ts +5 -1
  9. package/dist_ts/db/documents/classes.email-domain.doc.js +26 -2
  10. package/dist_ts/db/documents/classes.smartmta-storage.doc.d.ts +8 -0
  11. package/dist_ts/db/documents/classes.smartmta-storage.doc.js +91 -0
  12. package/dist_ts/db/documents/index.d.ts +1 -0
  13. package/dist_ts/db/documents/index.js +2 -1
  14. package/dist_ts/dns/manager.dns.d.ts +6 -0
  15. package/dist_ts/dns/manager.dns.js +81 -32
  16. package/dist_ts/dns/providers/cloudflare.provider.d.ts +2 -0
  17. package/dist_ts/dns/providers/cloudflare.provider.js +45 -28
  18. package/dist_ts/email/classes.email-domain.manager.d.ts +43 -38
  19. package/dist_ts/email/classes.email-domain.manager.js +391 -324
  20. package/dist_ts/email/classes.email-settings.manager.js +2 -1
  21. package/dist_ts/email/classes.mail-dns-sync.d.ts +58 -17
  22. package/dist_ts/email/classes.mail-dns-sync.js +857 -141
  23. package/dist_ts/email/classes.mail-edge-eligibility.d.ts +55 -0
  24. package/dist_ts/email/classes.mail-edge-eligibility.js +168 -0
  25. package/dist_ts/email/classes.mail-egress-coordinator.d.ts +3 -1
  26. package/dist_ts/email/classes.mail-egress-coordinator.js +43 -22
  27. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +27 -0
  28. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +109 -0
  29. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +6 -8
  30. package/dist_ts/email/classes.smartmta-storage-manager.js +63 -86
  31. package/dist_ts/email/classes.workapp-mail-manager.d.ts +3 -0
  32. package/dist_ts/email/classes.workapp-mail-manager.js +63 -14
  33. package/dist_ts/email/index.d.ts +3 -0
  34. package/dist_ts/email/index.js +4 -1
  35. package/dist_ts/email/smartmta-storage-policy.d.ts +9 -0
  36. package/dist_ts/email/smartmta-storage-policy.js +58 -0
  37. package/dist_ts/opsserver/handlers/email-domain.handler.d.ts +1 -0
  38. package/dist_ts/opsserver/handlers/email-domain.handler.js +138 -22
  39. package/dist_ts/opsserver/handlers/stats.handler.js +3 -5
  40. package/dist_ts/plugins.d.ts +2 -1
  41. package/dist_ts/plugins.js +3 -2
  42. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
  43. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
  44. package/dist_ts/remoteingress/classes.tunnel-manager.js +40 -2
  45. package/dist_ts_interfaces/data/dns-record.d.ts +6 -0
  46. package/dist_ts_interfaces/data/email-domain.d.ts +81 -0
  47. package/dist_ts_interfaces/requests/email-domains.d.ts +25 -17
  48. package/dist_ts_migrations/index.d.ts +2 -0
  49. package/dist_ts_migrations/index.js +441 -16
  50. package/dist_ts_web/00_commitinfo_data.js +1 -1
  51. package/dist_ts_web/appstate/email-domains.d.ts +16 -0
  52. package/dist_ts_web/appstate/email-domains.js +140 -19
  53. package/dist_ts_web/elements/email/ops-view-email-domains.d.ts +1 -0
  54. package/dist_ts_web/elements/email/ops-view-email-domains.js +36 -13
  55. package/package.json +4 -3
  56. package/readme.md +13 -1
  57. package/ts/00_commitinfo_data.ts +1 -1
  58. package/ts/classes.dcrouter.ts +55 -11
  59. package/ts/db/documents/classes.dns-record.doc.ts +20 -0
  60. package/ts/db/documents/classes.email-domain.doc.ts +14 -0
  61. package/ts/db/documents/classes.smartmta-storage.doc.ts +25 -0
  62. package/ts/db/documents/index.ts +1 -0
  63. package/ts/dns/manager.dns.ts +89 -29
  64. package/ts/dns/providers/cloudflare.provider.ts +46 -27
  65. package/ts/email/classes.email-domain.manager.ts +416 -368
  66. package/ts/email/classes.email-settings.manager.ts +1 -0
  67. package/ts/email/classes.mail-dns-sync.ts +913 -146
  68. package/ts/email/classes.mail-edge-eligibility.ts +205 -0
  69. package/ts/email/classes.mail-egress-coordinator.ts +46 -23
  70. package/ts/email/classes.smartmta-blob-storage-manager.ts +138 -0
  71. package/ts/email/classes.smartmta-storage-manager.ts +86 -83
  72. package/ts/email/classes.workapp-mail-manager.ts +82 -16
  73. package/ts/email/index.ts +3 -0
  74. package/ts/email/smartmta-storage-policy.ts +73 -0
  75. package/ts/opsserver/handlers/email-domain.handler.ts +154 -21
  76. package/ts/opsserver/handlers/stats.handler.ts +2 -4
  77. package/ts/plugins.ts +2 -1
  78. package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
  79. package/ts/remoteingress/classes.tunnel-manager.ts +53 -7
  80. package/ts_web/00_commitinfo_data.ts +1 -1
  81. package/ts_web/appstate/email-domains.ts +187 -18
  82. package/ts_web/elements/email/ops-view-email-domains.ts +44 -12
@@ -37,7 +37,7 @@ import { DnsManager } from './dns/manager.dns.js';
37
37
  import { DnsServerRuntime } from './dns/classes.dns-server-runtime.js';
38
38
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
39
39
  import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js';
40
- import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEgressCoordinator, SmartMtaStorageManager, WorkAppMailManager, type TSmartMtaQueueItemLike } from './email/index.js';
40
+ import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
41
41
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
42
42
  import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailOutboundMode, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
43
43
  import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
@@ -62,6 +62,9 @@ export interface IDcRouterOptions {
62
62
  */
63
63
  emailConfig?: IUnifiedEmailServerOptions;
64
64
 
65
+ /** SmartBucket configuration for durable SmartMTA queue and attachment blobs. */
66
+ emailBlobStorage?: ISmartMtaBlobStorageConfig;
67
+
65
68
  /** Outbound SMTP connection mode. Defaults to direct unless DB settings override it. */
66
69
  emailOutboundMode?: TEmailOutboundMode;
67
70
 
@@ -278,7 +281,8 @@ export class DcRouter {
278
281
  listener: (...args: any[]) => void;
279
282
  }> = [];
280
283
 
281
- public storageManager: IStorageManagerLike;
284
+ public storageManager!: IStorageManagerLike;
285
+ private smartMtaBlobStorageManager?: SmartMtaBlobStorageManager;
282
286
 
283
287
  // Unified database (smartdata + LocalSmartDb or external MongoDB)
284
288
  public dcRouterDb?: DcRouterDb;
@@ -311,6 +315,7 @@ export class DcRouter {
311
315
  public acceptedEmailSpool: AcceptedEmailSpool;
312
316
  public mailEgressCoordinator: MailEgressCoordinator;
313
317
  public mailDnsSync: MailDnsSync;
318
+ public mailEdgeEligibility: MailEdgeEligibility;
314
319
  public securityPolicyManager?: SecurityPolicyManager;
315
320
 
316
321
  // Auto-discovered public IP (populated by generateAuthoritativeRecords)
@@ -372,13 +377,12 @@ export class DcRouter {
372
377
  // Resolve all data paths from baseDir
373
378
  this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
374
379
  paths.ensureDataDirectories(this.resolvedPaths);
375
- this.storageManager = new SmartMtaStorageManager(
376
- plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
377
- );
378
380
  this.workAppMailManager = new WorkAppMailManager(this);
379
381
  this.acceptedEmailSpool = new AcceptedEmailSpool(this);
380
- this.mailEgressCoordinator = new MailEgressCoordinator(this);
381
- this.mailDnsSync = new MailDnsSync(this);
382
+ const mailEgressIdentitySource = new ReleasedRemoteIngressEgressIdentitySource(this);
383
+ this.mailEdgeEligibility = new MailEdgeEligibility(this, mailEgressIdentitySource);
384
+ this.mailEgressCoordinator = new MailEgressCoordinator(this, this.mailEdgeEligibility);
385
+ this.mailDnsSync = new MailDnsSync(this, this.mailEdgeEligibility);
382
386
  this.smartAcmeLifecycle = new SmartAcmeLifecycle(this);
383
387
  this.vpnAccessResolver = new VpnAccessResolver(this);
384
388
  this.remoteIngressHubLifecycle = new RemoteIngressHubLifecycle(this);
@@ -803,6 +807,22 @@ export class DcRouter {
803
807
  })
804
808
  .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
805
809
  );
810
+
811
+ // Mail DNS reconciliation starts last: it needs persistence, provider
812
+ // state, the SMTP DKIM creator, and live tunnel topology. Earlier events
813
+ // only mark the reconciler dirty and are consumed here.
814
+ this.serviceManager.addService(
815
+ new plugins.taskbuffer.Service('MailDnsSync')
816
+ .optional()
817
+ .dependsOn('DnsManager', 'EmailDomainManager', 'EmailServer', 'RemoteIngress')
818
+ .withStart(async () => {
819
+ await this.mailDnsSync.start();
820
+ })
821
+ .withStop(async () => {
822
+ await this.mailDnsSync.stop();
823
+ })
824
+ .withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
825
+ );
806
826
  }
807
827
 
808
828
  // VPN Server: optional, depends on SmartProxy
@@ -1011,6 +1031,10 @@ export class DcRouter {
1011
1031
  const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version, {
1012
1032
  remoteIngressHubSettings: this.getRemoteIngressHubSettingsMigrationSeed(),
1013
1033
  emailServerSettings: this.getEmailSettingsMigrationSeed(),
1034
+ legacySmartMtaStoragePath: plugins.path.join(
1035
+ this.resolvedPaths.dataDir,
1036
+ 'smartmta-storage',
1037
+ ),
1014
1038
  });
1015
1039
  const migrationResult = await migration.run();
1016
1040
  if (migrationResult.stepsApplied.length > 0) {
@@ -1022,6 +1046,8 @@ export class DcRouter {
1022
1046
  logger.log('info', `smartmigration: fresh install stamped to ${migrationResult.currentVersionAfter}`);
1023
1047
  }
1024
1048
 
1049
+ this.storageManager = new SmartMtaStorageManager(this.dcRouterDb.getDb());
1050
+
1025
1051
  // Start the cache cleaner for TTL-based document cleanup
1026
1052
  const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
1027
1053
  this.cacheCleaner = new CacheCleaner(this.dcRouterDb, {
@@ -1629,14 +1655,32 @@ export class DcRouter {
1629
1655
  : mappedEmailPorts.includes(configuredSecurePort) && !baseEmailConfig.ports.includes(configuredSecurePort)
1630
1656
  ? configuredSecurePort
1631
1657
  : portMapping[configuredSecurePort] || configuredSecurePort + 10000;
1658
+ const queueBehavior = { ...baseEmailConfig.queue } as Record<string, unknown>;
1659
+ delete queueBehavior.storageMode;
1660
+ delete queueBehavior.storageManager;
1661
+ delete queueBehavior.storageType;
1662
+ delete queueBehavior.persistentPath;
1663
+ if (this.options.emailBlobStorage && !this.smartMtaBlobStorageManager) {
1664
+ this.smartMtaBlobStorageManager = await SmartMtaBlobStorageManager.create(
1665
+ this.options.emailBlobStorage,
1666
+ );
1667
+ }
1668
+ const queueOptions: IUnifiedEmailServerOptions['queue'] = this.smartMtaBlobStorageManager
1669
+ ? {
1670
+ ...queueBehavior,
1671
+ storageMode: 'managed',
1672
+ storageManager: this.smartMtaBlobStorageManager,
1673
+ }
1674
+ : {
1675
+ ...queueBehavior,
1676
+ storageMode: 'memory',
1677
+ };
1678
+
1632
1679
  const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
1633
1680
  ...this.options.emailConfig,
1634
1681
  ports: mappedEmailPorts,
1635
1682
  persistRoutes: this.options.emailConfig.persistRoutes ?? false,
1636
- queue: {
1637
- ...this.options.emailConfig.queue,
1638
- storageType: 'memory',
1639
- },
1683
+ queue: queueOptions,
1640
1684
  outbound: {
1641
1685
  ...baseEmailConfig.outbound,
1642
1686
  connectionProxyProvider: (context) => this.mailEgressCoordinator.provideConnectionProxy(context),
@@ -44,6 +44,26 @@ export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc,
44
44
  @plugins.smartdata.svDb()
45
45
  public createdBy!: string;
46
46
 
47
+ @plugins.smartdata.svDb()
48
+ public managedBy?: string;
49
+
50
+ @plugins.smartdata.svDb()
51
+ public managedOwnerId?: string;
52
+
53
+ @plugins.smartdata.svDb()
54
+ public managedRecordKey?: string;
55
+
56
+ public async createSavableObject(): Promise<DnsRecordDoc> {
57
+ const savable = await super.createSavableObject() as DnsRecordDoc;
58
+ // SmartData persists decorated `undefined` values through `$set`. Managed
59
+ // metadata must be truly absent so partial indexes and ownership checks do
60
+ // not accidentally classify operator/provider records as reconciler-owned.
61
+ if (!this.managedBy) delete (savable as any).managedBy;
62
+ if (!this.managedOwnerId) delete (savable as any).managedOwnerId;
63
+ if (!this.managedRecordKey) delete (savable as any).managedRecordKey;
64
+ return savable;
65
+ }
66
+
47
67
  constructor() {
48
68
  super();
49
69
  }
@@ -5,6 +5,8 @@ import type {
5
5
  IEmailDomainRateLimits,
6
6
  IEmailDomainDnsStatus,
7
7
  IEmailDomainRemoteIngress,
8
+ IEmailDomainReconciliation,
9
+ IEmailDkimMaterial,
8
10
  } from '../../../ts_interfaces/data/email-domain.js';
9
11
 
10
12
  const getDb = () => DcRouterDb.getInstance().getDb();
@@ -36,6 +38,18 @@ export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomain
36
38
  @plugins.smartdata.svDb()
37
39
  public dnsStatus!: IEmailDomainDnsStatus;
38
40
 
41
+ @plugins.smartdata.svDb()
42
+ public reconciliation?: IEmailDomainReconciliation;
43
+
44
+ @plugins.smartdata.svDb()
45
+ public activeDkim?: IEmailDkimMaterial;
46
+
47
+ @plugins.smartdata.svDb()
48
+ public pendingDkim?: IEmailDkimMaterial;
49
+
50
+ @plugins.smartdata.svDb()
51
+ public retiringDkim?: IEmailDkimMaterial[];
52
+
39
53
  @plugins.smartdata.svDb()
40
54
  public createdAt!: string;
41
55
 
@@ -0,0 +1,25 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+
4
+ const getDb = () => DcRouterDb.getInstance().getDb();
5
+
6
+ @plugins.smartdata.Collection(() => getDb())
7
+ export class SmartMtaStorageDoc extends plugins.smartdata.SmartDataDbDoc<
8
+ SmartMtaStorageDoc,
9
+ SmartMtaStorageDoc
10
+ > {
11
+ @plugins.smartdata.svDb()
12
+ public id!: string;
13
+
14
+ @plugins.smartdata.svDb()
15
+ public key!: string;
16
+
17
+ @plugins.smartdata.svDb()
18
+ public value!: string;
19
+
20
+ @plugins.smartdata.svDb()
21
+ public createdAt!: string;
22
+
23
+ @plugins.smartdata.svDb()
24
+ public updatedAt!: string;
25
+ }
@@ -41,3 +41,4 @@ export * from './classes.acme-config.doc.js';
41
41
  // Email domain management
42
42
  export * from './classes.email-domain.doc.js';
43
43
  export * from './classes.email-server-settings.doc.js';
44
+ export * from './classes.smartmta-storage.doc.js';
@@ -88,10 +88,12 @@ export class DnsManager {
88
88
  let registered = 0;
89
89
  for (const domain of dcrouterDomains) {
90
90
  const records = await DnsRecordDoc.findByDomainId(domain.id);
91
- for (const rec of records) {
92
- this.registerRecordWithDnsServer(rec);
93
- registered++;
91
+ const rrsets = new Set(records.map((record) => this.rrsetKey(record.name, record.type)));
92
+ for (const rrset of rrsets) {
93
+ const [name, type] = rrset.split('|') as [string, TDnsRecordType];
94
+ await this.refreshLocalRrset(domain.id, name, type);
94
95
  }
96
+ registered += records.length;
95
97
  await this.registerAuthoritativeZoneDefaults(domain);
96
98
  }
97
99
  logger.log(
@@ -167,6 +169,26 @@ export class DnsManager {
167
169
  });
168
170
  }
169
171
 
172
+ private rrsetKey(name: string, type: TDnsRecordType): string {
173
+ return `${name.toLowerCase()}|${type}`;
174
+ }
175
+
176
+ /** Rebuild one authoritative RRset atomically from persisted rows. */
177
+ private async refreshLocalRrset(
178
+ domainId: string,
179
+ name: string,
180
+ type: TDnsRecordType,
181
+ ): Promise<void> {
182
+ if (!this.dnsServer) return;
183
+ this.dnsServer.unregisterHandler(name, [type]);
184
+ const records = await DnsRecordDoc.findByDomainId(domainId);
185
+ for (const record of records) {
186
+ if (record.name.toLowerCase() === name.toLowerCase() && record.type === type) {
187
+ this.registerRecordWithDnsServer(record);
188
+ }
189
+ }
190
+ }
191
+
170
192
  private parseRecordData(type: TDnsRecordType, value: string): any {
171
193
  switch (type) {
172
194
  case 'A':
@@ -608,9 +630,16 @@ export class DnsManager {
608
630
  const doc = await DomainDoc.findById(id);
609
631
  if (!doc) return false;
610
632
  const records = await DnsRecordDoc.findByDomainId(id);
633
+ const localRrsets = new Set(records.map((record) => this.rrsetKey(record.name, record.type)));
611
634
  for (const r of records) {
612
635
  await r.delete();
613
636
  }
637
+ if (doc.source === 'dcrouter' && this.dnsServer) {
638
+ for (const rrset of localRrsets) {
639
+ const [name, type] = rrset.split('|') as [string, TDnsRecordType];
640
+ this.dnsServer.unregisterHandler(name, [type]);
641
+ }
642
+ }
614
643
  await doc.delete();
615
644
  return true;
616
645
  }
@@ -631,13 +660,34 @@ export class DnsManager {
631
660
  }
632
661
  const providerRecords = await client.listRecords(doc.name);
633
662
 
634
- // Drop existing records and replace
663
+ // Merge by provider id, then exact tuple. Replacing the collection would
664
+ // erase explicit ownership metadata from reconciler-managed rows.
635
665
  const existing = await DnsRecordDoc.findByDomainId(id);
636
- for (const r of existing) {
637
- await r.delete();
638
- }
666
+ const remaining = new Set(existing);
639
667
  for (const pr of providerRecords) {
640
- await this.createSyncedRecord(id, pr, doc.createdBy);
668
+ const match = existing.find((record) => record.providerRecordId === pr.providerRecordId)
669
+ || existing.find((record) => (
670
+ record.name.toLowerCase() === pr.name.toLowerCase()
671
+ && record.type === pr.type
672
+ && record.value.trim() === pr.value.trim()
673
+ ));
674
+ if (!match) {
675
+ await this.createSyncedRecord(id, pr, doc.createdBy);
676
+ continue;
677
+ }
678
+ remaining.delete(match);
679
+ match.name = pr.name.toLowerCase();
680
+ match.type = pr.type;
681
+ match.value = pr.value;
682
+ match.ttl = pr.ttl;
683
+ match.proxied = pr.proxied;
684
+ match.source = 'synced';
685
+ match.providerRecordId = pr.providerRecordId;
686
+ match.updatedAt = Date.now();
687
+ await match.save();
688
+ }
689
+ for (const staleMirror of remaining) {
690
+ await staleMirror.delete();
641
691
  }
642
692
  doc.lastSyncedAt = Date.now();
643
693
  doc.updatedAt = doc.lastSyncedAt;
@@ -811,6 +861,9 @@ export class DnsManager {
811
861
  ttl?: number;
812
862
  proxied?: boolean;
813
863
  createdBy: string;
864
+ managedBy?: string;
865
+ managedOwnerId?: string;
866
+ managedRecordKey?: string;
814
867
  }): Promise<{ success: boolean; id?: string; message?: string }> {
815
868
  const domain = await DomainDoc.findById(args.domainId);
816
869
  if (!domain) return { success: false, message: 'Domain not found' };
@@ -828,6 +881,9 @@ export class DnsManager {
828
881
  doc.createdAt = now;
829
882
  doc.updatedAt = now;
830
883
  doc.createdBy = args.createdBy;
884
+ doc.managedBy = args.managedBy;
885
+ doc.managedOwnerId = args.managedOwnerId;
886
+ doc.managedRecordKey = args.managedRecordKey;
831
887
 
832
888
  if (domain.source === 'provider') {
833
889
  // Push to provider first; only persist locally on success
@@ -849,12 +905,12 @@ export class DnsManager {
849
905
  } catch (err: unknown) {
850
906
  return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
851
907
  }
852
- } else {
853
- // dcrouter-hosted / authoritative — register with embedded DnsServer immediately
854
- this.registerRecordWithDnsServer(doc);
855
908
  }
856
909
 
857
910
  await doc.save();
911
+ if (domain.source === 'dcrouter') {
912
+ await this.refreshLocalRrset(doc.domainId, doc.name, doc.type);
913
+ }
858
914
  return { success: true, id: doc.id };
859
915
  }
860
916
 
@@ -870,6 +926,8 @@ export class DnsManager {
870
926
  const domain = await DomainDoc.findById(doc.domainId);
871
927
  if (!domain) return { success: false, message: 'Parent domain not found' };
872
928
 
929
+ const previousName = doc.name;
930
+ const previousType = doc.type;
873
931
  if (args.name !== undefined) doc.name = args.name.toLowerCase();
874
932
  if (args.value !== undefined) doc.value = args.value;
875
933
  if (args.ttl !== undefined) doc.ttl = args.ttl;
@@ -893,12 +951,15 @@ export class DnsManager {
893
951
  } catch (err: unknown) {
894
952
  return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
895
953
  }
896
- } else {
897
- // Re-register the local record so the new closure picks up the updated fields
898
- this.registerRecordWithDnsServer(doc);
899
954
  }
900
955
 
901
956
  await doc.save();
957
+ if (domain.source === 'dcrouter') {
958
+ await this.refreshLocalRrset(doc.domainId, previousName, previousType);
959
+ if (previousName.toLowerCase() !== doc.name.toLowerCase()) {
960
+ await this.refreshLocalRrset(doc.domainId, doc.name, doc.type);
961
+ }
962
+ }
902
963
  return { success: true };
903
964
  }
904
965
 
@@ -909,24 +970,23 @@ export class DnsManager {
909
970
  if (!domain) return { success: false, message: 'Parent domain not found' };
910
971
 
911
972
  if (domain.source === 'provider') {
912
- if (domain.providerId && doc.providerRecordId) {
913
- const client = await this.getProviderClientById(domain.providerId);
914
- if (client) {
915
- try {
916
- await client.deleteRecord(domain.name, doc.providerRecordId);
917
- } catch (err: unknown) {
918
- return { success: false, message: `Provider rejected delete: ${(err as Error).message}` };
919
- }
920
- }
973
+ if (!domain.providerId || !doc.providerRecordId) {
974
+ return { success: false, message: 'Provider record metadata missing' };
975
+ }
976
+ const client = await this.getProviderClientById(domain.providerId);
977
+ if (!client) {
978
+ return { success: false, message: 'Provider client unavailable' };
979
+ }
980
+ try {
981
+ await client.deleteRecord(domain.name, doc.providerRecordId);
982
+ } catch (err: unknown) {
983
+ return { success: false, message: `Provider rejected delete: ${(err as Error).message}` };
921
984
  }
922
985
  }
923
- // For dcrouter-hosted records: unregister the handler from the embedded DnsServer
924
- // so the record stops being served immediately (not just after restart).
925
- if (domain.source === 'dcrouter' && this.dnsServer) {
926
- this.unregisterRecordFromDnsServer(doc);
927
- }
928
-
929
986
  await doc.delete();
987
+ if (domain.source === 'dcrouter') {
988
+ await this.refreshLocalRrset(doc.domainId, doc.name, doc.type);
989
+ }
930
990
  return { success: true };
931
991
  }
932
992
 
@@ -49,17 +49,21 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
49
49
  }
50
50
 
51
51
  public async listRecords(domain: string): Promise<IProviderRecord[]> {
52
- const records = await this.cfAccount.recordManager.listRecords(domain);
52
+ const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
53
+ const records: any[] = [];
54
+ // RecordManager.listRecords() intentionally converts provider failures to
55
+ // an empty array. DNS reconciliation must distinguish an empty zone from
56
+ // an unavailable provider, so use the raw paginated iterator and let
57
+ // errors propagate to the durable reconciliation outcome.
58
+ for await (const record of (this.cfAccount as any).apiAccount.dns.records.list({
59
+ zone_id: zoneId,
60
+ per_page: 100,
61
+ })) {
62
+ records.push(record);
63
+ }
53
64
  return records
54
65
  .filter((r) => this.isSupportedType(r.type))
55
- .map((r) => ({
56
- providerRecordId: r.id,
57
- name: r.name,
58
- type: r.type as TDnsRecordType,
59
- value: r.content,
60
- ttl: r.ttl,
61
- proxied: r.proxied,
62
- }));
66
+ .map((r) => this.fromApiRecord(r));
63
67
  }
64
68
 
65
69
  public async createRecord(
@@ -71,21 +75,14 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
71
75
  zone_id: zoneId,
72
76
  type: record.type,
73
77
  name: record.name,
74
- content: record.value,
75
78
  ttl: record.ttl ?? 1, // 1 = automatic
79
+ ...this.toApiRecordValue(record),
76
80
  };
77
81
  if (record.proxied !== undefined) {
78
82
  apiRecord.proxied = record.proxied;
79
83
  }
80
84
  const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
81
- return {
82
- providerRecordId: created.id,
83
- name: created.name,
84
- type: created.type as TDnsRecordType,
85
- value: created.content,
86
- ttl: created.ttl,
87
- proxied: created.proxied,
88
- };
85
+ return this.fromApiRecord(created);
89
86
  }
90
87
 
91
88
  public async updateRecord(
@@ -98,8 +95,8 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
98
95
  zone_id: zoneId,
99
96
  type: record.type,
100
97
  name: record.name,
101
- content: record.value,
102
98
  ttl: record.ttl ?? 1,
99
+ ...this.toApiRecordValue(record),
103
100
  };
104
101
  if (record.proxied !== undefined) {
105
102
  apiRecord.proxied = record.proxied;
@@ -108,14 +105,7 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
108
105
  providerRecordId,
109
106
  apiRecord,
110
107
  );
111
- return {
112
- providerRecordId: updated.id,
113
- name: updated.name,
114
- type: updated.type as TDnsRecordType,
115
- value: updated.content,
116
- ttl: updated.ttl,
117
- proxied: updated.proxied,
118
- };
108
+ return this.fromApiRecord(updated);
119
109
  }
120
110
 
121
111
  public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
@@ -128,4 +118,33 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
128
118
  private isSupportedType(type: string): boolean {
129
119
  return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
130
120
  }
121
+
122
+ private toApiRecordValue(record: IProviderRecordInput): { content: string; priority?: number } {
123
+ if (record.type !== 'MX') {
124
+ return { content: record.value };
125
+ }
126
+ const match = record.value.trim().match(/^(\d+)\s+(.+)$/);
127
+ if (!match) {
128
+ throw new Error(`CloudflareDnsProvider: invalid MX value "${record.value}"; expected "<priority> <exchange>"`);
129
+ }
130
+ const priority = Number.parseInt(match[1], 10);
131
+ if (!Number.isInteger(priority) || priority < 0 || priority > 65535) {
132
+ throw new Error(`CloudflareDnsProvider: invalid MX priority in "${record.value}"`);
133
+ }
134
+ return { content: match[2], priority };
135
+ }
136
+
137
+ private fromApiRecord(record: any): IProviderRecord {
138
+ const value = record.type === 'MX'
139
+ ? `${Number(record.priority)} ${record.content}`
140
+ : String(record.content);
141
+ return {
142
+ providerRecordId: String(record.id),
143
+ name: String(record.name),
144
+ type: record.type as TDnsRecordType,
145
+ value,
146
+ ttl: Number(record.ttl),
147
+ ...(record.proxied !== undefined ? { proxied: Boolean(record.proxied) } : {}),
148
+ };
149
+ }
131
150
  }