@serve.zone/dcrouter 17.0.0 → 17.1.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 (139) hide show
  1. package/.smartconfig.json +5 -2
  2. package/deno.json +1 -1
  3. package/dist_serve/bundle.js +1316 -1151
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/classes.dcrouter.d.ts +3 -1
  6. package/dist_ts/classes.dcrouter.js +110 -49
  7. package/dist_ts/db/classes.cache.cleaner.d.ts +3 -0
  8. package/dist_ts/db/classes.cache.cleaner.js +7 -3
  9. package/dist_ts/db/classes.dcrouter-db.js +1 -2
  10. package/dist_ts/db/documents/classes.cached.email.d.ts +41 -4
  11. package/dist_ts/db/documents/classes.cached.email.js +36 -5
  12. package/dist_ts/db/documents/classes.dns-record.doc.js +3 -1
  13. package/dist_ts/dns/classes.dns-server-runtime.d.ts +0 -1
  14. package/dist_ts/dns/classes.dns-server-runtime.js +4 -36
  15. package/dist_ts/dns/index.d.ts +1 -0
  16. package/dist_ts/dns/index.js +2 -1
  17. package/dist_ts/dns/manager.dns.d.ts +1 -0
  18. package/dist_ts/dns/manager.dns.js +49 -14
  19. package/dist_ts/dns/providers/cloudflare.provider.js +19 -4
  20. package/dist_ts/dns/txt-record-presentation.d.ts +20 -0
  21. package/dist_ts/dns/txt-record-presentation.js +131 -0
  22. package/dist_ts/email/classes.accepted-email-spool.d.ts +32 -3
  23. package/dist_ts/email/classes.accepted-email-spool.js +174 -13
  24. package/dist_ts/email/classes.email-domain.manager.d.ts +14 -1
  25. package/dist_ts/email/classes.email-domain.manager.js +151 -8
  26. package/dist_ts/email/classes.email-settings.manager.js +2 -2
  27. package/dist_ts/email/classes.mail-dns-sync.d.ts +31 -5
  28. package/dist_ts/email/classes.mail-dns-sync.js +508 -117
  29. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +2 -2
  30. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +4 -3
  31. package/dist_ts/email/classes.workapp-mail-manager.d.ts +1 -0
  32. package/dist_ts/email/classes.workapp-mail-manager.js +26 -2
  33. package/dist_ts/email/index.d.ts +2 -0
  34. package/dist_ts/email/index.js +3 -1
  35. package/dist_ts/email/mail-dns-txt-policy.d.ts +18 -0
  36. package/dist_ts/email/mail-dns-txt-policy.js +466 -0
  37. package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +47 -0
  38. package/dist_ts/email/reconcile-mail-dns-singleton.js +159 -0
  39. package/dist_ts/opsserver/classes.opsserver.d.ts +8 -0
  40. package/dist_ts/opsserver/classes.opsserver.js +34 -1
  41. package/dist_ts/opsserver/handlers/certificate.handler.js +25 -4
  42. package/dist_ts/opsserver/handlers/config.handler.js +9 -5
  43. package/dist_ts/opsserver/handlers/dns-provider.handler.js +19 -2
  44. package/dist_ts/opsserver/handlers/dns-record.handler.js +25 -4
  45. package/dist_ts/opsserver/handlers/domain.handler.js +37 -3
  46. package/dist_ts/opsserver/handlers/email-domain.handler.js +5 -7
  47. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +1 -0
  48. package/dist_ts/opsserver/handlers/email-ops.handler.js +26 -7
  49. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  50. package/dist_ts/opsserver/handlers/index.js +2 -1
  51. package/dist_ts/opsserver/handlers/logs.handler.d.ts +1 -1
  52. package/dist_ts/opsserver/handlers/logs.handler.js +3 -3
  53. package/dist_ts/opsserver/handlers/realtime.handler.d.ts +37 -0
  54. package/dist_ts/opsserver/handlers/realtime.handler.js +154 -0
  55. package/dist_ts/opsserver/handlers/remoteingress.handler.js +22 -1
  56. package/dist_ts/opsserver/handlers/route-management.handler.js +47 -5
  57. package/dist_ts/paths.d.ts +0 -11
  58. package/dist_ts/paths.js +1 -22
  59. package/dist_ts/plugins.d.ts +0 -34
  60. package/dist_ts/plugins.js +1 -62
  61. package/dist_ts_interfaces/data/email-domain.d.ts +14 -0
  62. package/dist_ts_interfaces/requests/config.d.ts +1 -1
  63. package/dist_ts_interfaces/requests/email-ops.d.ts +34 -1
  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 +38 -0
  67. package/dist_ts_interfaces/requests/realtime.js +11 -0
  68. package/dist_ts_migrations/index.d.ts +6 -0
  69. package/dist_ts_migrations/index.js +204 -9
  70. package/dist_ts_web/00_commitinfo_data.js +1 -1
  71. package/dist_ts_web/appstate/certificates.js +9 -2
  72. package/dist_ts_web/appstate/domains.js +16 -5
  73. package/dist_ts_web/appstate/email-domains.d.ts +3 -0
  74. package/dist_ts_web/appstate/email-domains.js +47 -14
  75. package/dist_ts_web/appstate/email-ops.d.ts +1 -0
  76. package/dist_ts_web/appstate/email-ops.js +20 -2
  77. package/dist_ts_web/appstate/realtime.d.ts +27 -0
  78. package/dist_ts_web/appstate/realtime.js +71 -0
  79. package/dist_ts_web/appstate/remoteingress.js +9 -3
  80. package/dist_ts_web/appstate/routes.js +16 -5
  81. package/dist_ts_web/appstate/runtime.js +135 -7
  82. package/dist_ts_web/appstate.d.ts +1 -0
  83. package/dist_ts_web/appstate.js +2 -1
  84. package/dist_ts_web/elements/email/email-domain-creation-progress.d.ts +33 -0
  85. package/dist_ts_web/elements/email/email-domain-creation-progress.js +257 -0
  86. package/dist_ts_web/elements/email/ops-view-email-domains.js +233 -68
  87. package/dist_ts_web/elements/email/ops-view-emails.d.ts +4 -2
  88. package/dist_ts_web/elements/email/ops-view-emails.js +25 -13
  89. package/dist_ts_web/elements/overview/ops-view-config.js +5 -5
  90. package/package.json +22 -19
  91. package/ts/00_commitinfo_data.ts +1 -1
  92. package/ts/classes.dcrouter.ts +121 -54
  93. package/ts/db/classes.cache.cleaner.ts +18 -3
  94. package/ts/db/classes.dcrouter-db.ts +0 -2
  95. package/ts/db/documents/classes.cached.email.ts +78 -4
  96. package/ts/db/documents/classes.dns-record.doc.ts +1 -0
  97. package/ts/dns/classes.dns-server-runtime.ts +9 -45
  98. package/ts/dns/index.ts +1 -0
  99. package/ts/dns/manager.dns.ts +50 -16
  100. package/ts/dns/providers/cloudflare.provider.ts +21 -3
  101. package/ts/dns/txt-record-presentation.ts +134 -0
  102. package/ts/email/classes.accepted-email-spool.ts +220 -14
  103. package/ts/email/classes.email-domain.manager.ts +201 -7
  104. package/ts/email/classes.email-settings.manager.ts +1 -1
  105. package/ts/email/classes.mail-dns-sync.ts +588 -122
  106. package/ts/email/classes.smartmta-blob-storage-manager.ts +3 -2
  107. package/ts/email/classes.workapp-mail-manager.ts +34 -1
  108. package/ts/email/index.ts +2 -0
  109. package/ts/email/mail-dns-txt-policy.ts +505 -0
  110. package/ts/email/reconcile-mail-dns-singleton.ts +224 -0
  111. package/ts/opsserver/classes.opsserver.ts +42 -0
  112. package/ts/opsserver/handlers/certificate.handler.ts +24 -3
  113. package/ts/opsserver/handlers/config.handler.ts +9 -5
  114. package/ts/opsserver/handlers/dns-provider.handler.ts +18 -1
  115. package/ts/opsserver/handlers/dns-record.handler.ts +24 -3
  116. package/ts/opsserver/handlers/domain.handler.ts +36 -2
  117. package/ts/opsserver/handlers/email-domain.handler.ts +4 -5
  118. package/ts/opsserver/handlers/email-ops.handler.ts +29 -6
  119. package/ts/opsserver/handlers/index.ts +1 -0
  120. package/ts/opsserver/handlers/logs.handler.ts +2 -2
  121. package/ts/opsserver/handlers/realtime.handler.ts +222 -0
  122. package/ts/opsserver/handlers/remoteingress.handler.ts +21 -0
  123. package/ts/opsserver/handlers/route-management.handler.ts +46 -4
  124. package/ts/paths.ts +0 -25
  125. package/ts/plugins.ts +0 -68
  126. package/ts_web/00_commitinfo_data.ts +1 -1
  127. package/ts_web/appstate/certificates.ts +6 -1
  128. package/ts_web/appstate/domains.ts +11 -4
  129. package/ts_web/appstate/email-domains.ts +68 -14
  130. package/ts_web/appstate/email-ops.ts +20 -1
  131. package/ts_web/appstate/realtime.ts +109 -0
  132. package/ts_web/appstate/remoteingress.ts +6 -2
  133. package/ts_web/appstate/routes.ts +11 -4
  134. package/ts_web/appstate/runtime.ts +176 -7
  135. package/ts_web/appstate.ts +1 -0
  136. package/ts_web/elements/email/email-domain-creation-progress.ts +340 -0
  137. package/ts_web/elements/email/ops-view-email-domains.ts +273 -74
  138. package/ts_web/elements/email/ops-view-emails.ts +23 -16
  139. package/ts_web/elements/overview/ops-view-config.ts +4 -4
@@ -648,7 +648,12 @@ export class DnsManager {
648
648
  * Force-resync a provider-managed domain: re-pull all records from the
649
649
  * provider API, replacing the cached DnsRecordDocs.
650
650
  */
651
- public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; message?: string }> {
651
+ public async syncDomain(id: string): Promise<{
652
+ success: boolean;
653
+ recordCount?: number;
654
+ listedProviderRecordIds?: string[];
655
+ message?: string;
656
+ }> {
652
657
  const doc = await DomainDoc.findById(id);
653
658
  if (!doc) return { success: false, message: 'Domain not found' };
654
659
  if (doc.source !== 'provider' || !doc.providerId) {
@@ -658,21 +663,39 @@ export class DnsManager {
658
663
  if (!client) {
659
664
  return { success: false, message: 'Provider client unavailable' };
660
665
  }
661
- const providerRecords = await client.listRecords(doc.name);
666
+ let providerRecords: IProviderRecord[];
667
+ try {
668
+ providerRecords = await client.listRecords(doc.name);
669
+ } catch (error: unknown) {
670
+ return { success: false, message: `Provider list failed: ${(error as Error).message}` };
671
+ }
672
+ const providerIds = providerRecords.map((record) => record.providerRecordId.trim());
673
+ if (providerIds.some((providerId) => !providerId)) {
674
+ return { success: false, message: 'Provider returned a record without a non-empty provider id' };
675
+ }
676
+ if (new Set(providerIds).size !== providerIds.length) {
677
+ return { success: false, message: 'Provider returned duplicate provider record ids' };
678
+ }
662
679
 
663
- // Merge by provider id, then exact tuple. Replacing the collection would
664
- // erase explicit ownership metadata from reconciler-managed rows.
680
+ // Provider ids are the only identity proof. Tuple matching could adopt an
681
+ // operator replacement and transfer reconciler ownership to the wrong row.
665
682
  const existing = await DnsRecordDoc.findByDomainId(id);
683
+ const localByProviderId = new Map<string, DnsRecordDoc>();
684
+ for (const record of existing) {
685
+ const providerId = record.providerRecordId?.trim();
686
+ if (!providerId) continue;
687
+ if (localByProviderId.has(providerId)) {
688
+ return { success: false, message: `Local mirror has duplicate provider record id ${providerId}` };
689
+ }
690
+ localByProviderId.set(providerId, record);
691
+ }
666
692
  const remaining = new Set(existing);
667
- for (const pr of providerRecords) {
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
- ));
693
+ for (let index = 0; index < providerRecords.length; index++) {
694
+ const pr = providerRecords[index];
695
+ const providerId = providerIds[index];
696
+ const match = localByProviderId.get(providerId);
674
697
  if (!match) {
675
- await this.createSyncedRecord(id, pr, doc.createdBy);
698
+ await this.createSyncedRecord(id, { ...pr, providerRecordId: providerId }, doc.createdBy);
676
699
  continue;
677
700
  }
678
701
  remaining.delete(match);
@@ -682,17 +705,22 @@ export class DnsManager {
682
705
  match.ttl = pr.ttl;
683
706
  match.proxied = pr.proxied;
684
707
  match.source = 'synced';
685
- match.providerRecordId = pr.providerRecordId;
708
+ match.providerRecordId = providerId;
686
709
  match.updatedAt = Date.now();
687
710
  await match.save();
688
711
  }
689
712
  for (const staleMirror of remaining) {
713
+ if (staleMirror.source !== 'synced' || !staleMirror.providerRecordId?.trim()) continue;
690
714
  await staleMirror.delete();
691
715
  }
692
716
  doc.lastSyncedAt = Date.now();
693
717
  doc.updatedAt = doc.lastSyncedAt;
694
718
  await doc.save();
695
- return { success: true, recordCount: providerRecords.length };
719
+ return {
720
+ success: true,
721
+ recordCount: providerRecords.length,
722
+ listedProviderRecordIds: providerIds,
723
+ };
696
724
  }
697
725
 
698
726
  // ==========================================================================
@@ -900,7 +928,9 @@ export class DnsManager {
900
928
  ttl: doc.ttl,
901
929
  proxied: doc.proxied,
902
930
  });
903
- doc.providerRecordId = created.providerRecordId;
931
+ const providerRecordId = created.providerRecordId.trim();
932
+ if (!providerRecordId) throw new Error('Provider returned an empty record id');
933
+ doc.providerRecordId = providerRecordId;
904
934
  doc.source = 'synced';
905
935
  } catch (err: unknown) {
906
936
  return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
@@ -1007,6 +1037,10 @@ export class DnsManager {
1007
1037
  pr: IProviderRecord,
1008
1038
  createdBy: string,
1009
1039
  ): Promise<void> {
1040
+ const providerRecordId = pr.providerRecordId.trim();
1041
+ if (!providerRecordId) {
1042
+ throw new Error('Cannot mirror a provider record without a non-empty provider id');
1043
+ }
1010
1044
  const now = Date.now();
1011
1045
  const doc = new DnsRecordDoc();
1012
1046
  doc.id = plugins.uuid.v4();
@@ -1017,7 +1051,7 @@ export class DnsManager {
1017
1051
  doc.ttl = pr.ttl;
1018
1052
  if (pr.proxied !== undefined) doc.proxied = pr.proxied;
1019
1053
  doc.source = 'synced';
1020
- doc.providerRecordId = pr.providerRecordId;
1054
+ doc.providerRecordId = providerRecordId;
1021
1055
  doc.createdAt = now;
1022
1056
  doc.updatedAt = now;
1023
1057
  doc.createdBy = createdBy;
@@ -8,6 +8,10 @@ import type {
8
8
  } from './interfaces.js';
9
9
  import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
10
10
  import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
11
+ import {
12
+ decodeTxtRecordContent,
13
+ encodeCloudflareTxtContent,
14
+ } from '../txt-record-presentation.js';
11
15
 
12
16
  /**
13
17
  * Cloudflare implementation of IDnsProviderClient.
@@ -120,6 +124,9 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
120
124
  }
121
125
 
122
126
  private toApiRecordValue(record: IProviderRecordInput): { content: string; priority?: number } {
127
+ if (record.type === 'TXT') {
128
+ return { content: encodeCloudflareTxtContent(record.value) };
129
+ }
123
130
  if (record.type !== 'MX') {
124
131
  return { content: record.value };
125
132
  }
@@ -135,9 +142,20 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
135
142
  }
136
143
 
137
144
  private fromApiRecord(record: any): IProviderRecord {
138
- const value = record.type === 'MX'
139
- ? `${Number(record.priority)} ${record.content}`
140
- : String(record.content);
145
+ let value: string;
146
+ if (record.type === 'MX') {
147
+ value = `${Number(record.priority)} ${record.content}`;
148
+ } else if (record.type === 'TXT') {
149
+ const decoded = decodeTxtRecordContent(String(record.content));
150
+ if (!decoded.ok) {
151
+ throw new Error(
152
+ `CloudflareDnsProvider: malformed TXT content for ${String(record.name)}: ${decoded.error}`,
153
+ );
154
+ }
155
+ value = decoded.value;
156
+ } else {
157
+ value = String(record.content);
158
+ }
141
159
  return {
142
160
  providerRecordId: String(record.id),
143
161
  name: String(record.name),
@@ -0,0 +1,134 @@
1
+ export type TTxtContentDecodeResult =
2
+ | { ok: true; value: string; wasPresentation: boolean }
3
+ | { ok: false; error: string };
4
+
5
+ const textEncoder = new TextEncoder();
6
+ const textDecoder = new TextDecoder('utf-8', { fatal: true });
7
+
8
+ function appendCharacterBytes(target: number[], character: string): void {
9
+ target.push(...textEncoder.encode(character));
10
+ }
11
+
12
+ /**
13
+ * Decode the representation returned by DNS provider APIs for TXT records.
14
+ *
15
+ * Cloudflare uses RFC 1035 presentation syntax (one or more adjacent quoted
16
+ * character strings). Some older API responses and dcrouter mirrors contain
17
+ * the already-decoded payload, so an unquoted input is accepted as canonical.
18
+ * A value that starts as presentation syntax must parse completely; malformed
19
+ * quoting or escaping is never repaired by stripping quote characters.
20
+ */
21
+ export function decodeTxtRecordContent(input: string): TTxtContentDecodeResult {
22
+ const source = input.trim();
23
+ if (!source.startsWith('"')) {
24
+ return { ok: true, value: source, wasPresentation: false };
25
+ }
26
+
27
+ const bytes: number[] = [];
28
+ let index = 0;
29
+ let chunkCount = 0;
30
+ while (index < source.length) {
31
+ while (/\s/.test(source[index] || '')) index++;
32
+ if (index >= source.length) break;
33
+ if (source[index] !== '"') {
34
+ return { ok: false, error: `Unexpected TXT presentation content at offset ${index}` };
35
+ }
36
+ chunkCount++;
37
+ index++;
38
+ let closed = false;
39
+ while (index < source.length) {
40
+ const character = source[index];
41
+ if (character === '"') {
42
+ index++;
43
+ closed = true;
44
+ break;
45
+ }
46
+ if (character === '\\') {
47
+ index++;
48
+ if (index >= source.length) {
49
+ return { ok: false, error: 'TXT presentation ends with an incomplete escape' };
50
+ }
51
+ if (/\d/.test(source[index])) {
52
+ const decimalEscape = source.slice(index, index + 3);
53
+ if (!/^\d{3}$/.test(decimalEscape)) {
54
+ return { ok: false, error: `Invalid TXT decimal escape at offset ${index - 1}` };
55
+ }
56
+ const byte = Number.parseInt(decimalEscape, 10);
57
+ if (byte > 255) {
58
+ return { ok: false, error: `TXT decimal escape exceeds 255 at offset ${index - 1}` };
59
+ }
60
+ bytes.push(byte);
61
+ index += 3;
62
+ continue;
63
+ }
64
+ const escapedCodePoint = source.codePointAt(index);
65
+ if (escapedCodePoint === undefined) {
66
+ return { ok: false, error: `Invalid TXT escape at offset ${index - 1}` };
67
+ }
68
+ const escapedCharacter = String.fromCodePoint(escapedCodePoint);
69
+ appendCharacterBytes(bytes, escapedCharacter);
70
+ index += escapedCharacter.length;
71
+ continue;
72
+ }
73
+ const codePoint = source.codePointAt(index);
74
+ if (codePoint === undefined) {
75
+ return { ok: false, error: `Invalid TXT character at offset ${index}` };
76
+ }
77
+ const nextCharacter = String.fromCodePoint(codePoint);
78
+ appendCharacterBytes(bytes, nextCharacter);
79
+ index += nextCharacter.length;
80
+ }
81
+ if (!closed) {
82
+ return { ok: false, error: 'TXT presentation contains an unterminated character string' };
83
+ }
84
+ while (/\s/.test(source[index] || '')) index++;
85
+ if (index < source.length && source[index] !== '"') {
86
+ return { ok: false, error: `Unexpected TXT presentation content at offset ${index}` };
87
+ }
88
+ }
89
+
90
+ if (chunkCount === 0) {
91
+ return { ok: false, error: 'TXT presentation contains no character strings' };
92
+ }
93
+ try {
94
+ return { ok: true, value: textDecoder.decode(Uint8Array.from(bytes)), wasPresentation: true };
95
+ } catch {
96
+ return { ok: false, error: 'TXT presentation is not valid UTF-8' };
97
+ }
98
+ }
99
+
100
+ function escapeTxtChunk(chunk: string): string {
101
+ let result = '';
102
+ for (const character of chunk) {
103
+ const codePoint = character.codePointAt(0)!;
104
+ if (character === '"' || character === '\\') {
105
+ result += `\\${character}`;
106
+ } else if (codePoint < 32 || codePoint === 127) {
107
+ for (const byte of textEncoder.encode(character)) {
108
+ result += `\\${byte.toString(10).padStart(3, '0')}`;
109
+ }
110
+ } else {
111
+ result += character;
112
+ }
113
+ }
114
+ return result;
115
+ }
116
+
117
+ /** Encode a canonical TXT payload as Cloudflare/RFC 1035 presentation text. */
118
+ export function encodeCloudflareTxtContent(value: string): string {
119
+ const chunks: string[] = [];
120
+ let current = '';
121
+ let currentBytes = 0;
122
+ for (const character of value) {
123
+ const characterBytes = textEncoder.encode(character).length;
124
+ if (currentBytes > 0 && currentBytes + characterBytes > 255) {
125
+ chunks.push(current);
126
+ current = '';
127
+ currentBytes = 0;
128
+ }
129
+ current += character;
130
+ currentBytes += characterBytes;
131
+ }
132
+ chunks.push(current);
133
+ return chunks.map((chunk) => `"${escapeTxtChunk(chunk)}"`).join(' ');
134
+ }