@serve.zone/dcrouter 18.0.2 → 18.1.1

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 (103) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +1529 -1382
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +7 -2
  5. package/dist_ts/classes.dcrouter.js +61 -9
  6. package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +25 -0
  7. package/dist_ts/db/documents/classes.authentication-event.doc.js +273 -0
  8. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.d.ts +25 -0
  9. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +200 -0
  10. package/dist_ts/db/documents/classes.smtp-account.doc.d.ts +31 -0
  11. package/dist_ts/db/documents/classes.smtp-account.doc.js +176 -0
  12. package/dist_ts/db/documents/index.d.ts +3 -0
  13. package/dist_ts/db/documents/index.js +4 -1
  14. package/dist_ts/email/classes.accepted-email-spool.d.ts +1 -0
  15. package/dist_ts/email/classes.accepted-email-spool.js +9 -1
  16. package/dist_ts/email/classes.email-settings.manager.d.ts +7 -0
  17. package/dist_ts/email/classes.email-settings.manager.js +30 -1
  18. package/dist_ts/email/classes.smtp-account.manager.d.ts +110 -0
  19. package/dist_ts/email/classes.smtp-account.manager.js +507 -0
  20. package/dist_ts/email/classes.workapp-mail-manager.d.ts +23 -4
  21. package/dist_ts/email/classes.workapp-mail-manager.js +39 -46
  22. package/dist_ts/email/index.d.ts +2 -0
  23. package/dist_ts/email/index.js +3 -1
  24. package/dist_ts/email/smtp-scram.d.ts +13 -0
  25. package/dist_ts/email/smtp-scram.js +19 -0
  26. package/dist_ts/monitoring/classes.metricsmanager.d.ts +19 -0
  27. package/dist_ts/monitoring/classes.metricsmanager.js +240 -44
  28. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  29. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  30. package/dist_ts/opsserver/handlers/admin.handler.d.ts +1 -0
  31. package/dist_ts/opsserver/handlers/admin.handler.js +61 -14
  32. package/dist_ts/opsserver/handlers/email-settings.handler.js +14 -2
  33. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  34. package/dist_ts/opsserver/handlers/index.js +2 -1
  35. package/dist_ts/opsserver/handlers/security.handler.js +21 -3
  36. package/dist_ts/opsserver/handlers/smtp-account.handler.d.ts +20 -0
  37. package/dist_ts/opsserver/handlers/smtp-account.handler.js +155 -0
  38. package/dist_ts/opsserver/handlers/stats.handler.js +27 -10
  39. package/dist_ts/security/classes.authentication-event-manager.d.ts +35 -0
  40. package/dist_ts/security/classes.authentication-event-manager.js +185 -0
  41. package/dist_ts/security/index.d.ts +1 -0
  42. package/dist_ts/security/index.js +2 -1
  43. package/dist_ts_interfaces/data/email-settings.d.ts +10 -0
  44. package/dist_ts_interfaces/data/index.d.ts +1 -0
  45. package/dist_ts_interfaces/data/index.js +2 -1
  46. package/dist_ts_interfaces/data/route-management.d.ts +1 -1
  47. package/dist_ts_interfaces/data/route-management.js +3 -1
  48. package/dist_ts_interfaces/data/smtp-account.d.ts +50 -0
  49. package/dist_ts_interfaces/data/smtp-account.js +9 -0
  50. package/dist_ts_interfaces/data/stats.d.ts +16 -0
  51. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  52. package/dist_ts_interfaces/requests/index.js +2 -1
  53. package/dist_ts_interfaces/requests/smtp-accounts.d.ts +125 -0
  54. package/dist_ts_interfaces/requests/smtp-accounts.js +2 -0
  55. package/dist_ts_migrations/index.js +186 -13
  56. package/dist_ts_web/00_commitinfo_data.js +1 -1
  57. package/dist_ts_web/appstate/smtp-accounts.d.ts +38 -0
  58. package/dist_ts_web/appstate/smtp-accounts.js +94 -0
  59. package/dist_ts_web/appstate.d.ts +1 -0
  60. package/dist_ts_web/appstate.js +2 -1
  61. package/dist_ts_web/elements/email/index.d.ts +1 -0
  62. package/dist_ts_web/elements/email/index.js +2 -1
  63. package/dist_ts_web/elements/email/ops-view-smtp-accounts.d.ts +25 -0
  64. package/dist_ts_web/elements/email/ops-view-smtp-accounts.js +598 -0
  65. package/dist_ts_web/elements/ops-dashboard.js +3 -1
  66. package/dist_ts_web/elements/overview/ops-view-overview.js +3 -2
  67. package/dist_ts_web/elements/security/ops-view-security-authentication.js +20 -13
  68. package/dist_ts_web/elements/security/ops-view-security-overview.js +8 -11
  69. package/dist_ts_web/router.js +2 -2
  70. package/package.json +2 -2
  71. package/readme.md +20 -0
  72. package/ts/00_commitinfo_data.ts +1 -1
  73. package/ts/classes.dcrouter.ts +66 -8
  74. package/ts/db/documents/classes.authentication-event.doc.ts +248 -0
  75. package/ts/db/documents/classes.email-traffic-bucket.doc.ts +161 -0
  76. package/ts/db/documents/classes.smtp-account.doc.ts +103 -0
  77. package/ts/db/documents/index.ts +3 -0
  78. package/ts/email/classes.accepted-email-spool.ts +8 -0
  79. package/ts/email/classes.email-settings.manager.ts +34 -0
  80. package/ts/email/classes.smtp-account.manager.ts +601 -0
  81. package/ts/email/classes.workapp-mail-manager.ts +53 -55
  82. package/ts/email/index.ts +2 -0
  83. package/ts/email/smtp-scram.ts +24 -0
  84. package/ts/monitoring/classes.metricsmanager.ts +287 -50
  85. package/ts/opsserver/classes.opsserver.ts +2 -0
  86. package/ts/opsserver/handlers/admin.handler.ts +74 -15
  87. package/ts/opsserver/handlers/email-settings.handler.ts +13 -1
  88. package/ts/opsserver/handlers/index.ts +1 -0
  89. package/ts/opsserver/handlers/security.handler.ts +23 -2
  90. package/ts/opsserver/handlers/smtp-account.handler.ts +199 -0
  91. package/ts/opsserver/handlers/stats.handler.ts +29 -9
  92. package/ts/security/classes.authentication-event-manager.ts +221 -0
  93. package/ts/security/index.ts +1 -0
  94. package/ts_web/00_commitinfo_data.ts +1 -1
  95. package/ts_web/appstate/smtp-accounts.ts +143 -0
  96. package/ts_web/appstate.ts +1 -0
  97. package/ts_web/elements/email/index.ts +1 -0
  98. package/ts_web/elements/email/ops-view-smtp-accounts.ts +586 -0
  99. package/ts_web/elements/ops-dashboard.ts +2 -0
  100. package/ts_web/elements/overview/ops-view-overview.ts +2 -1
  101. package/ts_web/elements/security/ops-view-security-authentication.ts +20 -13
  102. package/ts_web/elements/security/ops-view-security-overview.ts +7 -12
  103. package/ts_web/router.ts +1 -1
@@ -31,7 +31,7 @@ import { RemoteIngressHubLifecycle, RemoteIngressManager, TunnelManager, buildEd
31
31
  import { VpnAccessResolver, VpnManager, type IVpnManagerConfig } from './vpn/index.js';
32
32
  import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
33
33
  import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
34
- import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter } from './security/index.js';
34
+ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter, AuthenticationEventManager } from './security/index.js';
35
35
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
36
36
  import { applyDefaultInboundPolicy } from './email/inbound-policy.js';
37
37
  import { DnsManager } from './dns/manager.dns.js';
@@ -42,7 +42,7 @@ import { DomainOwnershipError } from './dns/domain-ownership.js';
42
42
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
43
43
  import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js';
44
44
  import { AcmePermanentFailureError, classifyAcmeFailure } from './acme/acme-failure-classification.js';
45
- import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
45
+ import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, SmtpAccountManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
46
46
  import { WebPushManager } from './webpush/index.js';
47
47
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
48
48
  import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailOutboundMode, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
@@ -277,6 +277,7 @@ export class DcRouter {
277
277
  public emailServer?: UnifiedEmailServer;
278
278
  public radiusServer?: RadiusServer;
279
279
  public opsServer!: OpsServer;
280
+ private opsServerStopped = false;
280
281
  public metricsManager?: MetricsManager;
281
282
  private emailEventSubscriptions: Array<{
282
283
  emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
@@ -326,11 +327,14 @@ export class DcRouter {
326
327
  public emailSettingsManager?: EmailSettingsManager;
327
328
  public emailDomainManager?: EmailDomainManager;
328
329
  public workAppMailManager: WorkAppMailManager;
330
+ /** Single composition owner of the email runtime auth block and route list. */
331
+ public smtpAccountManager: SmtpAccountManager;
329
332
  public acceptedEmailSpool: AcceptedEmailSpool;
330
333
  public mailEgressCoordinator: MailEgressCoordinator;
331
334
  public mailDnsSync: MailDnsSync;
332
335
  public mailEdgeEligibility: MailEdgeEligibility;
333
336
  public securityPolicyManager?: SecurityPolicyManager;
337
+ public authenticationEventManager: AuthenticationEventManager;
334
338
 
335
339
  // Auto-discovered public IP (populated by generateAuthoritativeRecords)
336
340
  public detectedPublicIp: string | null = null;
@@ -384,6 +388,9 @@ export class DcRouter {
384
388
  smartProxyConfig: coreTrafficConfig,
385
389
  };
386
390
 
391
+ // Authentication handlers and metrics share this single bounded event owner.
392
+ this.authenticationEventManager = new AuthenticationEventManager(this);
393
+
387
394
  // Capture smartmta's own log stream into the ops log buffer — without
388
395
  // this, mailer bridge crashes and delivery failures inside smartmta are
389
396
  // invisible to `getLogs` and postmortems.
@@ -392,6 +399,7 @@ export class DcRouter {
392
399
  // Resolve all data paths from baseDir
393
400
  this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
394
401
  this.workAppMailManager = new WorkAppMailManager(this);
402
+ this.smtpAccountManager = new SmtpAccountManager(this);
395
403
  this.acceptedEmailSpool = new AcceptedEmailSpool(this);
396
404
  const mailEgressIdentitySource = new ReleasedRemoteIngressEgressIdentitySource(this);
397
405
  this.mailEdgeEligibility = new MailEdgeEligibility(this, mailEgressIdentitySource);
@@ -426,11 +434,12 @@ export class DcRouter {
426
434
  new plugins.taskbuffer.Service('OpsServer')
427
435
  .critical()
428
436
  .withStart(async () => {
437
+ this.opsServerStopped = false;
429
438
  this.opsServer = new OpsServer(this);
430
439
  await this.opsServer.start();
431
440
  })
432
441
  .withStop(async () => {
433
- await this.opsServer?.stop();
442
+ await this.stopOpsServer();
434
443
  })
435
444
  .withRetry({ maxRetries: 0 }),
436
445
  );
@@ -477,11 +486,17 @@ export class DcRouter {
477
486
  .withRetry({ maxRetries: 0 }),
478
487
  );
479
488
 
480
- // MetricsManager: optional, depends on OpsServer
489
+ // Keep the database alive through the MetricsManager's final persistence drain.
490
+ const metricsDependencies = ['OpsServer'];
491
+ if (this.options.dbConfig?.enabled !== false) {
492
+ metricsDependencies.push('DcRouterDb');
493
+ }
494
+
495
+ // MetricsManager: optional, depends on OpsServer and the database when enabled.
481
496
  this.serviceManager.addService(
482
497
  new plugins.taskbuffer.Service('MetricsManager')
483
498
  .optional()
484
- .dependsOn('OpsServer')
499
+ .dependsOn(...metricsDependencies)
485
500
  .withStart(async () => {
486
501
  this.metricsManager = new MetricsManager(this);
487
502
  await this.metricsManager.start();
@@ -583,6 +598,26 @@ export class DcRouter {
583
598
  );
584
599
  }
585
600
 
601
+ // SmtpAccountManager: optional, depends on DcRouterDb — loads operator
602
+ // SMTP accounts into memory before EmailServer composes the auth block.
603
+ // The instance itself is constructed unconditionally in the constructor
604
+ // (composition must work with zero accounts when the DB is disabled);
605
+ // this service only manages the DB load.
606
+ if (this.options.dbConfig?.enabled !== false) {
607
+ this.serviceManager.addService(
608
+ new plugins.taskbuffer.Service('SmtpAccountManager')
609
+ .optional()
610
+ .dependsOn('DcRouterDb')
611
+ .withStart(async () => {
612
+ await this.smtpAccountManager.start();
613
+ })
614
+ .withStop(async () => {
615
+ await this.smtpAccountManager.stop();
616
+ })
617
+ .withRetry({ maxRetries: 1, baseDelayMs: 500 }),
618
+ );
619
+ }
620
+
586
621
  // Email Domain Manager: optional, depends on DcRouterDb
587
622
  if (this.options.dbConfig?.enabled !== false) {
588
623
  this.serviceManager.addService(
@@ -849,6 +884,8 @@ export class DcRouter {
849
884
  if (this.options.dbConfig?.enabled !== false) {
850
885
  const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
851
886
  emailServiceDeps.push('EmailDomainManager');
887
+ // Accounts must be loaded before setupUnifiedEmailHandling composes auth.
888
+ emailServiceDeps.push('SmtpAccountManager');
852
889
  // Explicit dependency: the outbound mode must come from the DB-backed settings;
853
890
  // without it, deliveries defer via the coordinator's strict mode resolution
854
891
  // instead of going direct.
@@ -1795,6 +1832,17 @@ export class DcRouter {
1795
1832
  return names;
1796
1833
  }
1797
1834
 
1835
+ private async stopOpsServer(): Promise<void> {
1836
+ if (this.opsServerStopped) return;
1837
+ this.opsServerStopped = true;
1838
+ try {
1839
+ await this.opsServer?.stop();
1840
+ } catch (error) {
1841
+ this.opsServerStopped = false;
1842
+ throw error;
1843
+ }
1844
+ }
1845
+
1798
1846
  public async stop() {
1799
1847
  logger.log('info', 'Stopping DcRouter services...');
1800
1848
 
@@ -1804,6 +1852,10 @@ export class DcRouter {
1804
1852
  this.serviceSubjectSubscription = undefined;
1805
1853
  }
1806
1854
 
1855
+ // Stop accepting authenticated requests before MetricsManager performs its
1856
+ // final durable authentication-event and email-traffic drain.
1857
+ await this.stopOpsServer();
1858
+
1807
1859
  // ServiceManager handles reverse-dependency-ordered shutdown
1808
1860
  await this.serviceManager.stop();
1809
1861
 
@@ -1903,7 +1955,7 @@ export class DcRouter {
1903
1955
  storageManager: this.smartMtaBlobStorageManager,
1904
1956
  };
1905
1957
 
1906
- let emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
1958
+ let emailConfig: IUnifiedEmailServerOptions = await this.smtpAccountManager.composeEmailConfig({
1907
1959
  ...this.options.emailConfig,
1908
1960
  ports: mappedEmailPorts,
1909
1961
  domains: applyDefaultInboundPolicy(this.options.emailConfig.domains),
@@ -1969,7 +2021,7 @@ export class DcRouter {
1969
2021
  repairResult.failedDomainIds.length > 0
1970
2022
  || repairResult.failedConfiguredDomains.length > 0
1971
2023
  )) {
1972
- emailConfig = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
2024
+ emailConfig = await this.smtpAccountManager.composeEmailConfig({
1973
2025
  ...emailConfig,
1974
2026
  domains: applyDefaultInboundPolicy(this.options.emailConfig?.domains),
1975
2027
  });
@@ -2003,6 +2055,13 @@ export class DcRouter {
2003
2055
  this.addEmailEventSubscription(emailServer, 'bridgeRecovered', () => {
2004
2056
  logger.log('info', 'Mailer bridge recovered after restart');
2005
2057
  });
2058
+
2059
+ // Live AUTH toggles restart only the SMTP listener; a failed restart
2060
+ // leaves SMTP down while the rest of the server runs. Surface it loudly —
2061
+ // isSmtpListenerActive() stays false until recovery.
2062
+ this.addEmailEventSubscription(emailServer, 'smtpListenerRestartFailed', (err: unknown) => {
2063
+ logger.log('error', `SMTP listener restart failed after an auth update — inbound SMTP is down until recovery: ${(err as Error)?.message || err}`);
2064
+ });
2006
2065
 
2007
2066
  // Start the server
2008
2067
  try {
@@ -2066,7 +2125,6 @@ export class DcRouter {
2066
2125
 
2067
2126
  this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
2068
2127
  const envelope = getEnvelope(item);
2069
- this.metricsManager!.trackEmailReceived(envelope.from);
2070
2128
  updateQueueSize();
2071
2129
  logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
2072
2130
  });
@@ -0,0 +1,248 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+ import type {
4
+ IAuthenticationEvent,
5
+ TAuthenticationFailureReason,
6
+ TAuthenticationSource,
7
+ TResolvedAuthenticationSource,
8
+ } from '../../../ts_interfaces/data/stats.js';
9
+
10
+ const DB_OPERATION_TIMEOUT_MS = 5_000;
11
+
12
+ const getDb = () => DcRouterDb.getInstance().getDb();
13
+
14
+ @plugins.smartdata.Collection(() => getDb())
15
+ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
16
+ AuthenticationEventDoc,
17
+ AuthenticationEventDoc
18
+ > implements IAuthenticationEvent {
19
+ @plugins.smartdata.unI()
20
+ @plugins.smartdata.svDb()
21
+ public id!: string;
22
+
23
+ @plugins.smartdata.svDb()
24
+ public timestamp!: number;
25
+
26
+ @plugins.smartdata.svDb()
27
+ public username!: string;
28
+
29
+ @plugins.smartdata.svDb()
30
+ public userId?: string;
31
+
32
+ @plugins.smartdata.svDb()
33
+ public success!: boolean;
34
+
35
+ @plugins.smartdata.svDb()
36
+ public requestedAuthSource!: TAuthenticationSource;
37
+
38
+ @plugins.smartdata.svDb()
39
+ public resolvedAuthSource?: TResolvedAuthenticationSource;
40
+
41
+ @plugins.smartdata.svDb()
42
+ public failureReason?: TAuthenticationFailureReason;
43
+
44
+ constructor() {
45
+ super();
46
+ }
47
+
48
+ public toApiObject(): IAuthenticationEvent {
49
+ return {
50
+ id: this.id,
51
+ timestamp: this.timestamp,
52
+ username: this.username,
53
+ success: this.success,
54
+ requestedAuthSource: this.requestedAuthSource,
55
+ ...(this.userId ? { userId: this.userId } : {}),
56
+ ...(this.resolvedAuthSource ? { resolvedAuthSource: this.resolvedAuthSource } : {}),
57
+ ...(this.failureReason ? { failureReason: this.failureReason } : {}),
58
+ };
59
+ }
60
+
61
+ private static async getNativeCollection() {
62
+ const smartdataCollection = (AuthenticationEventDoc as typeof AuthenticationEventDoc & {
63
+ collection: plugins.smartdata.SmartdataCollection<AuthenticationEventDoc>;
64
+ }).collection;
65
+ await smartdataCollection.init();
66
+ const probe = new AuthenticationEventDoc();
67
+ await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []);
68
+ await smartdataCollection.createRegularIndexes(probe.regularIndexes || []);
69
+ return smartdataCollection.mongoDbCollection;
70
+ }
71
+
72
+ private static toPersistedFields(eventArg: IAuthenticationEvent): IAuthenticationEvent {
73
+ return {
74
+ id: eventArg.id,
75
+ timestamp: eventArg.timestamp,
76
+ username: eventArg.username,
77
+ success: eventArg.success,
78
+ requestedAuthSource: eventArg.requestedAuthSource,
79
+ ...(eventArg.userId !== undefined ? { userId: eventArg.userId } : {}),
80
+ ...(eventArg.resolvedAuthSource !== undefined
81
+ ? { resolvedAuthSource: eventArg.resolvedAuthSource }
82
+ : {}),
83
+ ...(eventArg.failureReason !== undefined ? { failureReason: eventArg.failureReason } : {}),
84
+ };
85
+ }
86
+
87
+ private static validateEvent(eventArg: IAuthenticationEvent): void {
88
+ if (
89
+ typeof eventArg.id !== 'string'
90
+ || !eventArg.id.trim()
91
+ || !Number.isSafeInteger(eventArg.timestamp)
92
+ || eventArg.timestamp < 0
93
+ || typeof eventArg.username !== 'string'
94
+ || !eventArg.username.trim()
95
+ || typeof eventArg.success !== 'boolean'
96
+ || !['auto', 'local', 'idp.global'].includes(eventArg.requestedAuthSource)
97
+ ) {
98
+ throw new Error('Invalid authentication event');
99
+ }
100
+ if (
101
+ eventArg.userId !== undefined
102
+ && (typeof eventArg.userId !== 'string' || !eventArg.userId.trim())
103
+ ) {
104
+ throw new Error('Invalid authentication event user ID');
105
+ }
106
+ if (
107
+ eventArg.resolvedAuthSource !== undefined
108
+ && eventArg.resolvedAuthSource !== 'local'
109
+ && eventArg.resolvedAuthSource !== 'idp.global'
110
+ ) {
111
+ throw new Error('Invalid resolved authentication source');
112
+ }
113
+ if (
114
+ eventArg.failureReason !== undefined
115
+ && ![
116
+ 'invalidCredentials',
117
+ 'serviceUnavailable',
118
+ 'identityIssuanceFailed',
119
+ 'internalError',
120
+ ].includes(eventArg.failureReason)
121
+ ) {
122
+ throw new Error('Invalid authentication failure reason');
123
+ }
124
+ if (eventArg.success && eventArg.failureReason) {
125
+ throw new Error('Successful authentication events cannot carry a failure reason');
126
+ }
127
+ if (!eventArg.success && !eventArg.failureReason) {
128
+ throw new Error('Failed authentication events require a failure reason');
129
+ }
130
+ }
131
+
132
+ public static async upsertMany(eventsArg: IAuthenticationEvent[]): Promise<void> {
133
+ if (eventsArg.length === 0) return;
134
+ if (eventsArg.length > 500) {
135
+ throw new Error('AuthenticationEventDoc.upsertMany accepts at most 500 events');
136
+ }
137
+ for (const event of eventsArg) {
138
+ AuthenticationEventDoc.validateEvent(event);
139
+ }
140
+ const collection = await AuthenticationEventDoc.getNativeCollection();
141
+ await collection.bulkWrite(
142
+ eventsArg.map((event) => {
143
+ const timestamp = new Date(event.timestamp).toISOString();
144
+ const persistedEvent = AuthenticationEventDoc.toPersistedFields(event);
145
+ return {
146
+ updateOne: {
147
+ filter: { id: event.id },
148
+ update: {
149
+ $setOnInsert: {
150
+ ...persistedEvent,
151
+ _createdAt: timestamp,
152
+ _updatedAt: timestamp,
153
+ },
154
+ },
155
+ upsert: true,
156
+ },
157
+ };
158
+ }),
159
+ { ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS },
160
+ );
161
+ }
162
+
163
+ public static async getWindowSummary(
164
+ cutoffArg: number,
165
+ limitArg = 100,
166
+ ): Promise<{
167
+ successes: number;
168
+ failures: number;
169
+ events: IAuthenticationEvent[];
170
+ }> {
171
+ if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
172
+ throw new Error('AuthenticationEventDoc.getWindowSummary requires a valid cutoff');
173
+ }
174
+ if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) {
175
+ throw new Error('AuthenticationEventDoc.getWindowSummary requires a limit from 1 to 500');
176
+ }
177
+ const collection = await AuthenticationEventDoc.getNativeCollection();
178
+ const selector = { timestamp: { $gte: cutoffArg } };
179
+ const cursor = collection
180
+ .find(selector, { timeoutMS: DB_OPERATION_TIMEOUT_MS })
181
+ .sort({ timestamp: -1, id: -1 })
182
+ .limit(limitArg);
183
+ try {
184
+ const [successes, failures, rows] = await Promise.all([
185
+ collection.countDocuments(
186
+ { ...selector, success: true },
187
+ { timeoutMS: DB_OPERATION_TIMEOUT_MS },
188
+ ),
189
+ collection.countDocuments(
190
+ { ...selector, success: false },
191
+ { timeoutMS: DB_OPERATION_TIMEOUT_MS },
192
+ ),
193
+ cursor.toArray(),
194
+ ]);
195
+ return {
196
+ successes,
197
+ failures,
198
+ events: rows.map((row) => AuthenticationEventDoc
199
+ .createInstanceFromMongoDbNativeDoc(row as any)
200
+ .toApiObject()),
201
+ };
202
+ } finally {
203
+ await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
204
+ }
205
+ }
206
+
207
+ public static async findExistingIds(idsArg: string[]): Promise<Set<string>> {
208
+ const ids = [...new Set(idsArg)];
209
+ if (ids.length === 0) return new Set();
210
+ if (ids.length > 500) {
211
+ throw new Error('AuthenticationEventDoc.findExistingIds accepts at most 500 ids');
212
+ }
213
+ const collection = await AuthenticationEventDoc.getNativeCollection();
214
+ const rows = await collection
215
+ .find(
216
+ { id: { $in: ids } },
217
+ { projection: { id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
218
+ )
219
+ .toArray();
220
+ return new Set(rows.map((row) => String(row.id)));
221
+ }
222
+
223
+ public static async pruneBefore(cutoffArg: number): Promise<number> {
224
+ if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
225
+ throw new Error('AuthenticationEventDoc.pruneBefore requires a valid cutoff');
226
+ }
227
+ const collection = await AuthenticationEventDoc.getNativeCollection();
228
+ let deletedCount = 0;
229
+ while (true) {
230
+ const rows = await collection
231
+ .find(
232
+ { timestamp: { $lt: cutoffArg } },
233
+ { projection: { _id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
234
+ )
235
+ .sort({ timestamp: 1, _id: 1 })
236
+ .limit(500)
237
+ .toArray();
238
+ if (rows.length === 0) break;
239
+ const result = await collection.deleteMany(
240
+ { _id: { $in: rows.map((row) => row._id) } },
241
+ { timeoutMS: DB_OPERATION_TIMEOUT_MS },
242
+ );
243
+ deletedCount += result.deletedCount;
244
+ if (rows.length < 500) break;
245
+ }
246
+ return deletedCount;
247
+ }
248
+ }
@@ -0,0 +1,161 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+
4
+ const DB_OPERATION_TIMEOUT_MS = 5_000;
5
+
6
+ const getDb = () => DcRouterDb.getInstance().getDb();
7
+
8
+ export interface IEmailTrafficBucketCounts {
9
+ sent: number;
10
+ received: number;
11
+ failed: number;
12
+ }
13
+
14
+ export interface IEmailTrafficBucketSnapshot extends IEmailTrafficBucketCounts {
15
+ bucketStart: number;
16
+ }
17
+
18
+ @plugins.smartdata.Collection(() => getDb())
19
+ export class EmailTrafficBucketDoc extends plugins.smartdata.SmartDataDbDoc<
20
+ EmailTrafficBucketDoc,
21
+ EmailTrafficBucketDoc
22
+ > implements IEmailTrafficBucketSnapshot {
23
+ @plugins.smartdata.unI()
24
+ @plugins.smartdata.svDb()
25
+ public id!: string;
26
+
27
+ @plugins.smartdata.svDb()
28
+ public bucketStart!: number;
29
+
30
+ @plugins.smartdata.svDb()
31
+ public sent: number = 0;
32
+
33
+ @plugins.smartdata.svDb()
34
+ public received: number = 0;
35
+
36
+ @plugins.smartdata.svDb()
37
+ public failed: number = 0;
38
+
39
+ @plugins.smartdata.svDb()
40
+ public createdAt!: number;
41
+
42
+ @plugins.smartdata.svDb()
43
+ public updatedAt!: number;
44
+
45
+ constructor() {
46
+ super();
47
+ }
48
+
49
+ private static async getNativeCollection() {
50
+ const smartdataCollection = (EmailTrafficBucketDoc as typeof EmailTrafficBucketDoc & {
51
+ collection: plugins.smartdata.SmartdataCollection<EmailTrafficBucketDoc>;
52
+ }).collection;
53
+ await smartdataCollection.init();
54
+ const probe = new EmailTrafficBucketDoc();
55
+ await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []);
56
+ await smartdataCollection.createRegularIndexes(probe.regularIndexes || []);
57
+ return smartdataCollection.mongoDbCollection;
58
+ }
59
+
60
+ private static validateSnapshot(snapshotArg: IEmailTrafficBucketSnapshot): void {
61
+ const counts = [snapshotArg.sent, snapshotArg.received, snapshotArg.failed];
62
+ if (
63
+ !Number.isSafeInteger(snapshotArg.bucketStart)
64
+ || snapshotArg.bucketStart < 0
65
+ || snapshotArg.bucketStart % 60_000 !== 0
66
+ || counts.some((count) => !Number.isSafeInteger(count) || count < 0)
67
+ ) {
68
+ throw new Error('Invalid email traffic bucket snapshot');
69
+ }
70
+ }
71
+
72
+ /** Persist retry-safe absolute monotonic counters. */
73
+ public static async persistAbsolute(snapshotsArg: IEmailTrafficBucketSnapshot[]): Promise<void> {
74
+ if (snapshotsArg.length === 0) return;
75
+ if (snapshotsArg.length > 500) {
76
+ throw new Error('EmailTrafficBucketDoc.persistAbsolute accepts at most 500 buckets');
77
+ }
78
+ for (const snapshot of snapshotsArg) {
79
+ EmailTrafficBucketDoc.validateSnapshot(snapshot);
80
+ }
81
+ const collection = await EmailTrafficBucketDoc.getNativeCollection();
82
+ const now = Date.now();
83
+ const updatedAt = new Date(now).toISOString();
84
+ await collection.bulkWrite(
85
+ snapshotsArg.map((snapshot) => ({
86
+ updateOne: {
87
+ filter: { bucketStart: snapshot.bucketStart },
88
+ update: {
89
+ $max: {
90
+ sent: snapshot.sent,
91
+ received: snapshot.received,
92
+ failed: snapshot.failed,
93
+ },
94
+ $set: {
95
+ updatedAt: now,
96
+ _updatedAt: updatedAt,
97
+ },
98
+ $setOnInsert: {
99
+ id: `email-traffic-${snapshot.bucketStart}`,
100
+ bucketStart: snapshot.bucketStart,
101
+ createdAt: now,
102
+ _createdAt: updatedAt,
103
+ },
104
+ },
105
+ upsert: true,
106
+ },
107
+ })),
108
+ { ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS },
109
+ );
110
+ }
111
+
112
+ public static async loadSince(cutoffArg: number): Promise<IEmailTrafficBucketSnapshot[]> {
113
+ if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
114
+ throw new Error('EmailTrafficBucketDoc.loadSince requires a valid cutoff');
115
+ }
116
+ const collection = await EmailTrafficBucketDoc.getNativeCollection();
117
+ const cursor = collection
118
+ .find(
119
+ { bucketStart: { $gte: cutoffArg } },
120
+ { timeoutMS: DB_OPERATION_TIMEOUT_MS },
121
+ )
122
+ .sort({ bucketStart: 1 });
123
+ try {
124
+ const rows = await cursor.toArray();
125
+ return rows.map((row) => ({
126
+ bucketStart: Number(row.bucketStart),
127
+ sent: Number(row.sent || 0),
128
+ received: Number(row.received || 0),
129
+ failed: Number(row.failed || 0),
130
+ }));
131
+ } finally {
132
+ await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
133
+ }
134
+ }
135
+
136
+ public static async pruneBefore(cutoffArg: number): Promise<number> {
137
+ if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
138
+ throw new Error('EmailTrafficBucketDoc.pruneBefore requires a valid cutoff');
139
+ }
140
+ const collection = await EmailTrafficBucketDoc.getNativeCollection();
141
+ let deletedCount = 0;
142
+ while (true) {
143
+ const rows = await collection
144
+ .find(
145
+ { bucketStart: { $lt: cutoffArg } },
146
+ { projection: { _id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
147
+ )
148
+ .sort({ bucketStart: 1, _id: 1 })
149
+ .limit(500)
150
+ .toArray();
151
+ if (rows.length === 0) break;
152
+ const result = await collection.deleteMany(
153
+ { _id: { $in: rows.map((row) => row._id) } },
154
+ { timeoutMS: DB_OPERATION_TIMEOUT_MS },
155
+ );
156
+ deletedCount += result.deletedCount;
157
+ if (rows.length < 500) break;
158
+ }
159
+ return deletedCount;
160
+ }
161
+ }