@serve.zone/dcrouter 16.4.1 → 16.5.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 (59) 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 +2 -1
  5. package/dist_ts/classes.dcrouter.js +20 -4
  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/dns/manager.dns.d.ts +6 -0
  11. package/dist_ts/dns/manager.dns.js +81 -32
  12. package/dist_ts/dns/providers/cloudflare.provider.d.ts +2 -0
  13. package/dist_ts/dns/providers/cloudflare.provider.js +45 -28
  14. package/dist_ts/email/classes.email-domain.manager.d.ts +43 -38
  15. package/dist_ts/email/classes.email-domain.manager.js +391 -324
  16. package/dist_ts/email/classes.mail-dns-sync.d.ts +58 -17
  17. package/dist_ts/email/classes.mail-dns-sync.js +857 -141
  18. package/dist_ts/email/classes.mail-edge-eligibility.d.ts +55 -0
  19. package/dist_ts/email/classes.mail-edge-eligibility.js +168 -0
  20. package/dist_ts/email/classes.mail-egress-coordinator.d.ts +3 -1
  21. package/dist_ts/email/classes.mail-egress-coordinator.js +43 -22
  22. package/dist_ts/email/classes.workapp-mail-manager.d.ts +3 -0
  23. package/dist_ts/email/classes.workapp-mail-manager.js +58 -12
  24. package/dist_ts/email/index.d.ts +1 -0
  25. package/dist_ts/email/index.js +2 -1
  26. package/dist_ts/opsserver/handlers/email-domain.handler.d.ts +1 -0
  27. package/dist_ts/opsserver/handlers/email-domain.handler.js +138 -22
  28. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
  29. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
  30. package/dist_ts/remoteingress/classes.tunnel-manager.js +40 -2
  31. package/dist_ts_interfaces/data/dns-record.d.ts +6 -0
  32. package/dist_ts_interfaces/data/email-domain.d.ts +81 -0
  33. package/dist_ts_interfaces/requests/email-domains.d.ts +25 -17
  34. package/dist_ts_migrations/index.js +152 -9
  35. package/dist_ts_web/00_commitinfo_data.js +1 -1
  36. package/dist_ts_web/appstate/email-domains.d.ts +16 -0
  37. package/dist_ts_web/appstate/email-domains.js +140 -19
  38. package/dist_ts_web/elements/email/ops-view-email-domains.d.ts +1 -0
  39. package/dist_ts_web/elements/email/ops-view-email-domains.js +36 -13
  40. package/package.json +3 -3
  41. package/readme.md +13 -1
  42. package/ts/00_commitinfo_data.ts +1 -1
  43. package/ts/classes.dcrouter.ts +22 -3
  44. package/ts/db/documents/classes.dns-record.doc.ts +20 -0
  45. package/ts/db/documents/classes.email-domain.doc.ts +14 -0
  46. package/ts/dns/manager.dns.ts +89 -29
  47. package/ts/dns/providers/cloudflare.provider.ts +46 -27
  48. package/ts/email/classes.email-domain.manager.ts +416 -368
  49. package/ts/email/classes.mail-dns-sync.ts +913 -146
  50. package/ts/email/classes.mail-edge-eligibility.ts +205 -0
  51. package/ts/email/classes.mail-egress-coordinator.ts +46 -23
  52. package/ts/email/classes.workapp-mail-manager.ts +69 -13
  53. package/ts/email/index.ts +1 -0
  54. package/ts/opsserver/handlers/email-domain.handler.ts +154 -21
  55. package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
  56. package/ts/remoteingress/classes.tunnel-manager.ts +53 -7
  57. package/ts_web/00_commitinfo_data.ts +1 -1
  58. package/ts_web/appstate/email-domains.ts +187 -18
  59. package/ts_web/elements/email/ops-view-email-domains.ts +44 -12
@@ -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
  }