@serve.zone/dcrouter 17.1.4 → 17.1.6

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 (49) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +559 -559
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.js +9 -3
  5. package/dist_ts/db/documents/classes.ip-intelligence.doc.d.ts +10 -0
  6. package/dist_ts/db/documents/classes.ip-intelligence.doc.js +63 -1
  7. package/dist_ts/dns/classes.dns-server-runtime.d.ts +1 -0
  8. package/dist_ts/dns/classes.dns-server-runtime.js +8 -4
  9. package/dist_ts/dns/manager.dns.js +23 -6
  10. package/dist_ts/email/classes.email-domain.manager.js +27 -15
  11. package/dist_ts/email/classes.mail-dns-sync.d.ts +19 -2
  12. package/dist_ts/email/classes.mail-dns-sync.js +158 -32
  13. package/dist_ts/email/index.d.ts +1 -0
  14. package/dist_ts/email/index.js +2 -1
  15. package/dist_ts/email/mail-dns-status.d.ts +5 -0
  16. package/dist_ts/email/mail-dns-status.js +31 -0
  17. package/dist_ts/monitoring/classes.metricscache.d.ts +2 -0
  18. package/dist_ts/monitoring/classes.metricscache.js +28 -6
  19. package/dist_ts/monitoring/classes.metricsmanager.d.ts +10 -0
  20. package/dist_ts/monitoring/classes.metricsmanager.js +99 -31
  21. package/dist_ts/opsserver/handlers/security.handler.js +24 -6
  22. package/dist_ts/opsserver/handlers/stats.handler.js +2 -3
  23. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -2
  24. package/dist_ts/security/classes.security-policy-manager.d.ts +17 -0
  25. package/dist_ts/security/classes.security-policy-manager.js +171 -26
  26. package/dist_ts_interfaces/requests/stats.d.ts +7 -0
  27. package/dist_ts_migrations/index.js +395 -9
  28. package/dist_ts_web/00_commitinfo_data.js +1 -1
  29. package/dist_ts_web/appstate/network.js +11 -2
  30. package/dist_ts_web/appstate/ui.js +1 -8
  31. package/package.json +3 -3
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts/classes.dcrouter.ts +11 -2
  34. package/ts/db/documents/classes.ip-intelligence.doc.ts +75 -0
  35. package/ts/dns/classes.dns-server-runtime.ts +9 -3
  36. package/ts/dns/manager.dns.ts +22 -5
  37. package/ts/email/classes.email-domain.manager.ts +23 -16
  38. package/ts/email/classes.mail-dns-sync.ts +172 -38
  39. package/ts/email/index.ts +1 -0
  40. package/ts/email/mail-dns-status.ts +35 -0
  41. package/ts/monitoring/classes.metricscache.ts +36 -8
  42. package/ts/monitoring/classes.metricsmanager.ts +113 -32
  43. package/ts/opsserver/handlers/security.handler.ts +35 -6
  44. package/ts/opsserver/handlers/stats.handler.ts +1 -2
  45. package/ts/remoteingress/classes.hub-lifecycle.ts +3 -1
  46. package/ts/security/classes.security-policy-manager.ts +193 -26
  47. package/ts_web/00_commitinfo_data.ts +1 -1
  48. package/ts_web/appstate/network.ts +12 -1
  49. package/ts_web/appstate/ui.ts +0 -9
@@ -18,6 +18,7 @@ import {
18
18
  MailEdgeEligibility,
19
19
  ReleasedRemoteIngressEgressIdentitySource,
20
20
  type IEligibleMailEdge,
21
+ type IMailEdgeEligibilityResult,
21
22
  } from './classes.mail-edge-eligibility.js';
22
23
  import {
23
24
  evaluateMailTxtIntent,
@@ -27,14 +28,17 @@ import {
27
28
  applyMailDnsVerificationResult,
28
29
  reconcileMailDnsSingleton,
29
30
  } from './reconcile-mail-dns-singleton.js';
31
+ import { projectMailDnsIntentAggregateStatus } from './mail-dns-status.js';
30
32
 
31
33
  export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
32
34
  export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
33
35
  export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
34
36
  export const MAIL_DNS_MAX_RETRY_INTERVAL_MS = 60 * 60_000;
37
+ export const MAIL_DNS_EDGE_ELIGIBILITY_DEBOUNCE_MS = 250;
38
+ export const MAIL_DNS_EDGE_ELIGIBILITY_COOLDOWN_MS = 5_000;
35
39
  export const MAIL_DNS_PUBLIC_RESOLVER_OPTIONS = {
36
- strategy: 'doh' as const,
37
- allowDohFallback: false,
40
+ strategy: 'prefer-udp' as const,
41
+ allowDohFallback: true,
38
42
  timeoutMs: 5_000,
39
43
  };
40
44
 
@@ -65,6 +69,28 @@ function normalizeHostname(value: string): string {
65
69
  return value.trim().toLowerCase().replace(/\.$/, '');
66
70
  }
67
71
 
72
+ function mailEdgeEligibilitySignature(result: IMailEdgeEligibilityResult): string {
73
+ const edges = result.edges.map((candidate) => {
74
+ const identities = candidate.identities.map((identity) => ({
75
+ family: identity.family,
76
+ address: identity.address,
77
+ heloHostname: normalizeHostname(identity.heloHostname),
78
+ proof: identity.proof,
79
+ }));
80
+ identities.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
81
+ return {
82
+ id: candidate.edge.id,
83
+ hostname: normalizeHostname(candidate.hostname),
84
+ tags: [...(candidate.edge.tags || [])].sort(),
85
+ publicIp: candidate.edge.publicIp || '',
86
+ publicIpV6: candidate.edge.publicIpV6 || '',
87
+ identities,
88
+ };
89
+ });
90
+ edges.sort((left, right) => left.id.localeCompare(right.id));
91
+ return JSON.stringify({ supported: result.supported, edges });
92
+ }
93
+
68
94
  function normalizeMx(value: string): string {
69
95
  const [priority, exchange = ''] = value.trim().split(/\s+/);
70
96
  return `${Number(priority)} ${normalizeHostname(exchange)}`;
@@ -175,7 +201,25 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
175
201
  };
176
202
  }
177
203
  if (result.status === 'missing') {
178
- return { status: 'missing', reason: result.missingReason };
204
+ const evidence = result.attempts.map((attempt) => {
205
+ if (attempt.status === 'missing') {
206
+ return `${attempt.transport}:missing:${attempt.missingReason}`;
207
+ }
208
+ if (attempt.status === 'error') {
209
+ return `${attempt.transport}:error:${attempt.error.code}`;
210
+ }
211
+ return `${attempt.transport}:success`;
212
+ }).join(', ');
213
+ const corroboratedMissing = (
214
+ result.attempts.length === 2
215
+ && result.attempts[0].transport === 'udp'
216
+ && result.attempts[0].status === 'missing'
217
+ && result.attempts[1].transport === 'doh'
218
+ && result.attempts[1].status === 'missing'
219
+ );
220
+ return corroboratedMissing
221
+ ? { status: 'missing', reason: `${result.missingReason}; attempts=${evidence}` }
222
+ : { status: 'error', reason: `unconfirmed ${result.missingReason}; attempts=${evidence || 'none'}` };
179
223
  }
180
224
  return {
181
225
  status: 'error',
@@ -190,7 +234,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
190
234
  return { valid: false, outcome: 'lookup-error', reason: `DNS lookup failed: ${query.reason}` };
191
235
  }
192
236
  if (query.status === 'missing') {
193
- return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing` };
237
+ return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing (${query.reason})` };
194
238
  }
195
239
  const values = query.values;
196
240
  if (intent.type === 'MX') {
@@ -255,7 +299,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
255
299
  return { valid: false, outcome: 'lookup-error', reason: `MX lookup failed: ${query.reason}` };
256
300
  }
257
301
  if (query.status === 'missing') {
258
- return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing` };
302
+ return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing (${query.reason})` };
259
303
  }
260
304
  return evaluateMailMxValues(intents, query.values, previousValues);
261
305
  } catch (error: unknown) {
@@ -319,6 +363,12 @@ interface IPlannedDomain {
319
363
  selectorCorrectSigningSupported: boolean;
320
364
  }
321
365
 
366
+ interface IZoneReconciliationResult {
367
+ errors: Map<string, IEmailDomainOperationError>;
368
+ provenProviderZoneIds: Set<string>;
369
+ readyZoneIds: Set<string>;
370
+ }
371
+
322
372
  /**
323
373
  * Sole owner of managed mail DNS. API, topology, timer, and startup events only
324
374
  * mark this globally serialized reconciler dirty.
@@ -334,6 +384,13 @@ export class MailDnsSync {
334
384
  private forceAll = false;
335
385
  private forcedDomainIds = new Set<string>();
336
386
  private edgeEligibility: MailEdgeEligibility;
387
+ private edgeEligibilitySignature?: string;
388
+ private edgeEligibilityCheckTimer?: ReturnType<typeof setTimeout>;
389
+ private edgeEligibilityCheckPromise?: Promise<void>;
390
+ private pendingEdgeEligibilityReason?: string;
391
+ private lastEdgeEligibilitySyncAt = 0;
392
+ private edgeEligibilityDebounceMs = MAIL_DNS_EDGE_ELIGIBILITY_DEBOUNCE_MS;
393
+ private edgeEligibilityCooldownMs = MAIL_DNS_EDGE_ELIGIBILITY_COOLDOWN_MS;
337
394
 
338
395
  constructor(
339
396
  private dcRouterRef: DcRouter,
@@ -352,12 +409,18 @@ export class MailDnsSync {
352
409
  }
353
410
  this.started = true;
354
411
  this.stopped = false;
412
+ this.edgeEligibilitySignature = undefined;
413
+ this.pendingEdgeEligibilityReason = undefined;
414
+ this.lastEdgeEligibilitySyncAt = 0;
355
415
  this.queueRequest(undefined, true);
356
416
  try {
357
417
  await this.kick('startup');
358
418
  } catch (error: unknown) {
359
419
  if (this.retryTimer) clearTimeout(this.retryTimer);
360
420
  this.retryTimer = undefined;
421
+ if (this.edgeEligibilityCheckTimer) clearTimeout(this.edgeEligibilityCheckTimer);
422
+ this.edgeEligibilityCheckTimer = undefined;
423
+ this.pendingEdgeEligibilityReason = undefined;
361
424
  this.started = false;
362
425
  this.dirty = false;
363
426
  throw error;
@@ -372,6 +435,12 @@ export class MailDnsSync {
372
435
  clearTimeout(this.retryTimer);
373
436
  this.retryTimer = undefined;
374
437
  }
438
+ if (this.edgeEligibilityCheckTimer) {
439
+ clearTimeout(this.edgeEligibilityCheckTimer);
440
+ this.edgeEligibilityCheckTimer = undefined;
441
+ }
442
+ this.pendingEdgeEligibilityReason = undefined;
443
+ await this.edgeEligibilityCheckPromise?.catch(() => undefined);
375
444
  await this.runPromise?.catch(() => undefined);
376
445
  this.verifier.destroy?.();
377
446
  }
@@ -385,6 +454,65 @@ export class MailDnsSync {
385
454
  });
386
455
  }
387
456
 
457
+ /**
458
+ * RemoteIngress topology events are advisory. Resolve the actual mail-edge
459
+ * eligibility snapshot and only reconcile DNS when its semantic shape
460
+ * changes. Volatile heartbeat/observation timestamps never enter the
461
+ * signature.
462
+ */
463
+ public requestEdgeEligibilityCheck(reason: string): void {
464
+ if (this.stopped || !this.started) return;
465
+ this.pendingEdgeEligibilityReason = reason;
466
+ if (this.edgeEligibilityCheckTimer || this.edgeEligibilityCheckPromise) return;
467
+ this.scheduleEdgeEligibilityCheck();
468
+ }
469
+
470
+ private scheduleEdgeEligibilityCheck(): void {
471
+ if (this.stopped || !this.started || !this.pendingEdgeEligibilityReason) return;
472
+ const cooldownRemaining = Math.max(
473
+ 0,
474
+ this.lastEdgeEligibilitySyncAt + this.edgeEligibilityCooldownMs - Date.now(),
475
+ );
476
+ const delay = Math.max(this.edgeEligibilityDebounceMs, cooldownRemaining);
477
+ this.edgeEligibilityCheckTimer = setTimeout(() => {
478
+ this.edgeEligibilityCheckTimer = undefined;
479
+ this.runEdgeEligibilityCheck().catch((error: unknown) => {
480
+ logger.log('error', `MailDnsSync edge eligibility check failed: ${(error as Error).message}`);
481
+ });
482
+ }, delay);
483
+ this.edgeEligibilityCheckTimer.unref?.();
484
+ }
485
+
486
+ private async runEdgeEligibilityCheck(): Promise<void> {
487
+ if (this.edgeEligibilityCheckPromise) return await this.edgeEligibilityCheckPromise;
488
+ const reason = this.pendingEdgeEligibilityReason;
489
+ this.pendingEdgeEligibilityReason = undefined;
490
+ if (!reason || this.stopped || !this.started) return;
491
+
492
+ this.edgeEligibilityCheckPromise = (async () => {
493
+ const result = await this.edgeEligibility.resolveForDomain();
494
+ const nextSignature = mailEdgeEligibilitySignature(result);
495
+ const previousSignature = this.edgeEligibilitySignature;
496
+ this.edgeEligibilitySignature = nextSignature;
497
+ if (
498
+ previousSignature === undefined
499
+ || previousSignature === nextSignature
500
+ || this.stopped
501
+ || !this.started
502
+ ) {
503
+ return;
504
+ }
505
+ this.lastEdgeEligibilitySyncAt = Date.now();
506
+ this.requestSync(reason);
507
+ })().finally(() => {
508
+ this.edgeEligibilityCheckPromise = undefined;
509
+ if (this.pendingEdgeEligibilityReason && !this.stopped && this.started) {
510
+ this.scheduleEdgeEligibilityCheck();
511
+ }
512
+ });
513
+ return await this.edgeEligibilityCheckPromise;
514
+ }
515
+
388
516
  /** Coalescing entry point used by API actions that need the durable outcome. */
389
517
  public async sync(reason: string, domainIds?: string[]): Promise<void> {
390
518
  if (this.stopped) throw new Error('MailDnsSync is stopped');
@@ -477,7 +605,8 @@ export class MailDnsSync {
477
605
  if (docs.length === 0) return;
478
606
 
479
607
  // Refresh the broad operational status independently from per-domain pins.
480
- await this.edgeEligibility.resolveForDomain();
608
+ const broadEligibility = await this.edgeEligibility.resolveForDomain();
609
+ this.edgeEligibilitySignature = mailEdgeEligibilitySignature(broadEligibility);
481
610
  const plans: IPlannedDomain[] = [];
482
611
  const deletingDocs: EmailDomainDoc[] = [];
483
612
  for (const loadedDoc of docs) {
@@ -520,7 +649,7 @@ export class MailDnsSync {
520
649
  if (outcome) Object.assign(intent, outcome);
521
650
  if (intent.error) errors.push(intent.error);
522
651
  }
523
- await this.validateAndActivate(plan, errors, zoneResult.errors);
652
+ await this.validateAndActivate(plan, errors, zoneResult, zones);
524
653
  }
525
654
 
526
655
  const cleanupProof = await this.refreshProviderZonesForCleanup(
@@ -900,12 +1029,10 @@ export class MailDnsSync {
900
1029
  private async reconcileZones(
901
1030
  intents: IEmailDnsRecordIntent[],
902
1031
  zones: DomainDoc[],
903
- ): Promise<{
904
- errors: Map<string, IEmailDomainOperationError>;
905
- provenProviderZoneIds: Set<string>;
906
- }> {
1032
+ ): Promise<IZoneReconciliationResult> {
907
1033
  const errors = new Map<string, IEmailDomainOperationError>();
908
1034
  const provenProviderZoneIds = new Set<string>();
1035
+ const readyZoneIds = new Set<string>();
909
1036
  const byZone = new Map<string, IEmailDnsRecordIntent[]>();
910
1037
  for (const intent of intents) {
911
1038
  if (!intent.domainId || intent.error) continue;
@@ -921,6 +1048,14 @@ export class MailDnsSync {
921
1048
  const result = await this.dcRouterRef.dnsManager!.syncDomain(zone.id);
922
1049
  if (!result.success) throw new Error(result.message || `Failed to refresh ${zone.name}`);
923
1050
  provenProviderZoneIds.add(zone.id);
1051
+ readyZoneIds.add(zone.id);
1052
+ }
1053
+ if (
1054
+ zone.source === 'dcrouter'
1055
+ && this.dcRouterRef.dnsServer
1056
+ && this.dcRouterRef.dnsManager?.dnsServer === this.dcRouterRef.dnsServer
1057
+ ) {
1058
+ readyZoneIds.add(zone.id);
924
1059
  }
925
1060
  for (const intent of this.sortIntents(zoneIntents)) {
926
1061
  await this.reconcileIntent(intent, zoneIntents);
@@ -939,7 +1074,7 @@ export class MailDnsSync {
939
1074
  }
940
1075
  }
941
1076
  }
942
- return { errors, provenProviderZoneIds };
1077
+ return { errors, provenProviderZoneIds, readyZoneIds };
943
1078
  }
944
1079
 
945
1080
  private sortIntents(intents: IEmailDnsRecordIntent[]): IEmailDnsRecordIntent[] {
@@ -1104,10 +1239,29 @@ export class MailDnsSync {
1104
1239
  private async validateAndActivate(
1105
1240
  plan: IPlannedDomain,
1106
1241
  errors: IEmailDomainOperationError[],
1107
- zoneErrors: Map<string, IEmailDomainOperationError>,
1242
+ zoneResult: IZoneReconciliationResult,
1243
+ zones: DomainDoc[],
1108
1244
  ): Promise<void> {
1245
+ for (const intent of plan.intents) {
1246
+ if (intent.error || zoneResult.readyZoneIds.has(intent.domainId)) continue;
1247
+ const zone = zones.find((candidate) => candidate.id === intent.domainId);
1248
+ const providerManaged = zone?.source === 'provider';
1249
+ const outcome: IEmailDomainOperationError = {
1250
+ code: providerManaged ? 'DNS_PROVIDER_ZONE_UNPROVEN' : 'DNS_SERVER_NOT_READY',
1251
+ message: providerManaged
1252
+ ? `DNS provider zone ${zone?.name || intent.domainId} was not proven by this reconciliation pass`
1253
+ : `Authoritative DNS runtime is not ready for ${zone?.name || intent.domainId}`,
1254
+ retryable: true,
1255
+ };
1256
+ intent.status = 'pending';
1257
+ intent.verificationOutcome = 'lookup-error';
1258
+ intent.error = { ...outcome, recordKey: intent.key };
1259
+ if (!errors.some((error) => error.code === outcome.code)) errors.push(outcome);
1260
+ }
1109
1261
  const verifiable = plan.intents.filter((intent) => (
1110
- !intent.error && !zoneErrors.has(intent.domainId)
1262
+ !intent.error
1263
+ && !zoneResult.errors.has(intent.domainId)
1264
+ && zoneResult.readyZoneIds.has(intent.domainId)
1111
1265
  ));
1112
1266
  const markResult = (intent: IEmailDnsRecordIntent, result: IMailDnsVerificationResult) => {
1113
1267
  const outcome = result.outcome || (result.valid ? 'valid' : 'propagation');
@@ -1265,13 +1419,13 @@ export class MailDnsSync {
1265
1419
  && plan.revision.mxSlots.length > 0;
1266
1420
  plan.doc.dnsStatus = {
1267
1421
  mx: mailPublicationReady
1268
- ? this.statusForIntent(plan.intents, (intent) => intent.type === 'MX')
1422
+ ? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'MX')
1269
1423
  : 'unchecked',
1270
1424
  spf: mailPublicationReady
1271
- ? this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1'))
1425
+ ? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1'))
1272
1426
  : 'unchecked',
1273
- dkim: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
1274
- dmarc: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
1427
+ dkim: projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
1428
+ dmarc: projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
1275
1429
  lastCheckedAt: now,
1276
1430
  };
1277
1431
  plan.doc.updatedAt = now;
@@ -1307,26 +1461,6 @@ export class MailDnsSync {
1307
1461
  doc.dkim.lastRotatedAt = now;
1308
1462
  }
1309
1463
 
1310
- private statusForIntent(
1311
- intents: IEmailDnsRecordIntent[],
1312
- predicate: (intent: IEmailDnsRecordIntent) => boolean,
1313
- ): 'valid' | 'missing' | 'invalid' | 'unchecked' {
1314
- const selected = intents.filter(predicate);
1315
- if (selected.length === 0) return 'missing';
1316
- if (selected.every((intent) => intent.status === 'valid')) return 'valid';
1317
- if (selected.some((intent) => (
1318
- intent.verificationOutcome === 'duplicate'
1319
- || intent.verificationOutcome === 'conflict'
1320
- || intent.verificationOutcome === 'invalid'
1321
- || intent.status === 'conflict'
1322
- || intent.status === 'failed'
1323
- ))) return 'invalid';
1324
- if (selected.some((intent) => intent.verificationOutcome === 'lookup-error')) {
1325
- return 'unchecked';
1326
- }
1327
- return 'missing';
1328
- }
1329
-
1330
1464
  private async refreshProviderZonesForCleanup(
1331
1465
  docs: EmailDomainDoc[],
1332
1466
  zones: DomainDoc[],
package/ts/email/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './classes.email-domain.manager.js';
3
3
  export * from './classes.email-route-builder.js';
4
4
  export * from './classes.mail-dns-sync.js';
5
5
  export * from './mail-dns-txt-policy.js';
6
+ export * from './mail-dns-status.js';
6
7
  export * from './reconcile-mail-dns-singleton.js';
7
8
  export * from './classes.mail-egress-coordinator.js';
8
9
  export * from './classes.mail-edge-eligibility.js';
@@ -0,0 +1,35 @@
1
+ import type {
2
+ IEmailDnsRecordIntent,
3
+ TDnsRecordStatus,
4
+ } from '../../ts_interfaces/data/email-domain.js';
5
+
6
+ /** Project one durable reconciliation intent into the operator-facing DNS status. */
7
+ export function projectMailDnsIntentStatus(
8
+ intent: IEmailDnsRecordIntent | undefined,
9
+ ): TDnsRecordStatus {
10
+ if (!intent) return 'unchecked';
11
+ if (
12
+ intent.verificationOutcome === 'duplicate'
13
+ || intent.verificationOutcome === 'conflict'
14
+ || intent.verificationOutcome === 'invalid'
15
+ || intent.status === 'conflict'
16
+ || intent.status === 'failed'
17
+ ) return 'invalid';
18
+ if (intent.verificationOutcome === 'missing') return 'missing';
19
+ if (intent.verificationOutcome === 'valid' && intent.status === 'valid') return 'valid';
20
+ return 'unchecked';
21
+ }
22
+
23
+ /** Aggregate matching intents with deterministic operator-facing precedence. */
24
+ export function projectMailDnsIntentAggregateStatus(
25
+ intents: IEmailDnsRecordIntent[],
26
+ predicate: (intent: IEmailDnsRecordIntent) => boolean,
27
+ ): TDnsRecordStatus {
28
+ const selected = intents.filter(predicate);
29
+ if (selected.length === 0) return 'unchecked';
30
+ const statuses = selected.map(projectMailDnsIntentStatus);
31
+ if (statuses.includes('invalid')) return 'invalid';
32
+ if (statuses.includes('missing')) return 'missing';
33
+ if (statuses.includes('unchecked')) return 'unchecked';
34
+ return 'valid';
35
+ }
@@ -1,10 +1,12 @@
1
1
  export interface ICacheEntry<T> {
2
2
  data: T;
3
3
  timestamp: number;
4
+ ttl: number;
4
5
  }
5
6
 
6
7
  export class MetricsCache {
7
8
  private cache = new Map<string, ICacheEntry<any>>();
9
+ private inFlight = new Map<string, Promise<any>>();
8
10
  private readonly defaultTTL: number;
9
11
 
10
12
  constructor(defaultTTL: number = 500) {
@@ -19,20 +21,44 @@ export class MetricsCache {
19
21
  const now = Date.now();
20
22
  const actualTTL = ttl ?? this.defaultTTL;
21
23
 
22
- if (cached && (now - cached.timestamp) < actualTTL) {
24
+ if (cached && (now - cached.timestamp) < cached.ttl) {
23
25
  return cached.data;
24
26
  }
27
+
28
+ if (cached) {
29
+ this.cache.delete(key);
30
+ }
31
+
32
+ const pending = this.inFlight.get(key) as Promise<T> | undefined;
33
+ if (pending) {
34
+ return pending;
35
+ }
25
36
 
26
37
  const result = computeFn();
27
38
 
28
39
  // Handle both sync and async compute functions
29
40
  if (result instanceof Promise) {
30
- return result.then(data => {
31
- this.cache.set(key, { data, timestamp: now });
32
- return data;
33
- });
41
+ let inFlightPromise: Promise<T>;
42
+ inFlightPromise = result.then(
43
+ (data) => {
44
+ if (this.inFlight.get(key) === inFlightPromise) {
45
+ this.inFlight.delete(key);
46
+ this.cache.set(key, { data, timestamp: Date.now(), ttl: actualTTL });
47
+ }
48
+ return data;
49
+ },
50
+ (error: unknown) => {
51
+ if (this.inFlight.get(key) === inFlightPromise) {
52
+ this.inFlight.delete(key);
53
+ this.cache.delete(key);
54
+ }
55
+ throw error;
56
+ },
57
+ );
58
+ this.inFlight.set(key, inFlightPromise);
59
+ return inFlightPromise;
34
60
  } else {
35
- this.cache.set(key, { data: result, timestamp: now });
61
+ this.cache.set(key, { data: result, timestamp: now, ttl: actualTTL });
36
62
  return result;
37
63
  }
38
64
  }
@@ -42,6 +68,7 @@ export class MetricsCache {
42
68
  */
43
69
  public invalidate(key: string): void {
44
70
  this.cache.delete(key);
71
+ this.inFlight.delete(key);
45
72
  }
46
73
 
47
74
  /**
@@ -49,6 +76,7 @@ export class MetricsCache {
49
76
  */
50
77
  public clear(): void {
51
78
  this.cache.clear();
79
+ this.inFlight.clear();
52
80
  }
53
81
 
54
82
  /**
@@ -67,9 +95,9 @@ export class MetricsCache {
67
95
  public cleanup(): void {
68
96
  const now = Date.now();
69
97
  for (const [key, entry] of this.cache.entries()) {
70
- if (now - entry.timestamp > this.defaultTTL) {
98
+ if (now - entry.timestamp > entry.ttl) {
71
99
  this.cache.delete(key);
72
100
  }
73
101
  }
74
102
  }
75
- }
103
+ }