@serve.zone/dcrouter 18.9.3 → 18.9.4

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 (42) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.js +5 -4
  4. package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +0 -1
  5. package/dist_ts/db/documents/classes.authentication-event.doc.js +176 -259
  6. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +110 -188
  7. package/dist_ts/dns/manager.dns-authority.d.ts +5 -0
  8. package/dist_ts/dns/manager.dns-authority.js +97 -53
  9. package/dist_ts/dns/manager.dns.js +23 -5
  10. package/dist_ts/email/classes.email-domain.manager.js +14 -3
  11. package/dist_ts/email/classes.email-settings.manager.js +26 -6
  12. package/dist_ts/email/classes.mail-dns-sync.js +22 -2
  13. package/dist_ts/monitoring/classes.metricscache.d.ts +28 -19
  14. package/dist_ts/monitoring/classes.metricscache.js +133 -62
  15. package/dist_ts/monitoring/classes.metricsmanager.js +7 -5
  16. package/dist_ts/plugins.d.ts +2 -1
  17. package/dist_ts/plugins.js +3 -2
  18. package/dist_ts/remoteingress/classes.remoteingress-manager.js +46 -9
  19. package/dist_ts/security/classes.authentication-event-manager.d.ts +5 -1
  20. package/dist_ts/security/classes.authentication-event-manager.js +32 -22
  21. package/dist_ts/vpn/classes.vpn-manager.js +34 -2
  22. package/dist_ts_migrations/index.d.ts +4 -1
  23. package/dist_ts_migrations/index.js +18 -3
  24. package/dist_ts_web/00_commitinfo_data.js +1 -1
  25. package/package.json +7 -6
  26. package/readme.hints.md +72 -0
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/classes.dcrouter.ts +4 -3
  29. package/ts/db/documents/classes.authentication-event.doc.ts +64 -87
  30. package/ts/db/documents/classes.email-traffic-bucket.doc.ts +10 -9
  31. package/ts/dns/manager.dns-authority.ts +92 -54
  32. package/ts/dns/manager.dns.ts +20 -4
  33. package/ts/email/classes.email-domain.manager.ts +12 -2
  34. package/ts/email/classes.email-settings.manager.ts +23 -5
  35. package/ts/email/classes.mail-dns-sync.ts +23 -1
  36. package/ts/monitoring/classes.metricscache.ts +133 -79
  37. package/ts/monitoring/classes.metricsmanager.ts +6 -4
  38. package/ts/plugins.ts +2 -0
  39. package/ts/remoteingress/classes.remoteingress-manager.ts +42 -8
  40. package/ts/security/classes.authentication-event-manager.ts +31 -20
  41. package/ts/vpn/classes.vpn-manager.ts +32 -1
  42. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.9.3',
6
+ version: '18.9.4',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHNfd2ViLzAwX2NvbW1pdGluZm9fZGF0YS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLFVBQVUsR0FBRztJQUN4QixJQUFJLEVBQUUsc0JBQXNCO0lBQzVCLE9BQU8sRUFBRSxRQUFRO0lBQ2pCLFdBQVcsRUFBRSwwRUFBMEU7Q0FDeEYsQ0FBQSJ9
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "18.9.3",
4
+ "version": "18.9.4",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -18,11 +18,12 @@
18
18
  "@git.zone/tsbuild": "^4.4.2",
19
19
  "@git.zone/tsbundle": "^2.13.0",
20
20
  "@git.zone/tsdeno": "^1.7.0",
21
- "@git.zone/tsdocker": "^3.5.3",
21
+ "@git.zone/tsdocker": "^3.5.4",
22
22
  "@git.zone/tsrun": "^2.0.6",
23
23
  "@git.zone/tstest": "^4.0.0",
24
24
  "@git.zone/tswatch": "^3.3.5",
25
25
  "@push.rocks/smartdb-v2": "npm:@push.rocks/smartdb@2.14.6",
26
+ "@push.rocks/smartdb-v5.2": "npm:@push.rocks/smartdb@5.2.2",
26
27
  "@types/node": "26.4.1",
27
28
  "@types/web-push": "3.6.4"
28
29
  },
@@ -34,15 +35,15 @@
34
35
  "@apiclient.xyz/cloudflare": "^8.0.0",
35
36
  "@design.estate/dees-catalog": "^3.104.0",
36
37
  "@design.estate/dees-element": "^2.4.0",
37
- "@idp.global/sdk": "^15.0.1",
38
+ "@idp.global/sdk": "^16.0.0",
38
39
  "@push.rocks/lik": "^6.4.1",
39
40
  "@push.rocks/projectinfo": "^5.1.0",
40
41
  "@push.rocks/qenv": "^6.1.4",
41
42
  "@push.rocks/smartacme": "^9.5.0",
42
43
  "@push.rocks/smartbucket": "^4.13.0",
43
44
  "@push.rocks/smartchallenge": "^1.1.0",
44
- "@push.rocks/smartdata": "^7.4.0",
45
- "@push.rocks/smartdb": "5.2.2",
45
+ "@push.rocks/smartdata": "^11.9.0",
46
+ "@push.rocks/smartdb": "5.5.2",
46
47
  "@push.rocks/smartdns": "^8.2.1",
47
48
  "@push.rocks/smartfs": "^1.7.0",
48
49
  "@push.rocks/smartguard": "^3.1.2",
@@ -55,7 +56,7 @@
55
56
  "@push.rocks/smartnetwork": "^4.10.1",
56
57
  "@push.rocks/smartpath": "^6.0.0",
57
58
  "@push.rocks/smartpromise": "^4.2.5",
58
- "@push.rocks/smartproxy": "^27.20.0",
59
+ "@push.rocks/smartproxy": "^27.21.0",
59
60
  "@push.rocks/smartradius": "^1.3.0",
60
61
  "@push.rocks/smartrequest": "^5.0.4",
61
62
  "@push.rocks/smartrx": "^3.0.12",
package/readme.hints.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # Implementation Hints and Learnings
2
2
 
3
+ ## Bounded metrics and owned delegation probes (2026-09-05)
4
+
5
+ `MetricsCache` has separate aggregate and snapshot LRU stores: 256 aggregate
6
+ keys, 64 snapshot keys, and an 8 MiB estimated payload budget per store. The
7
+ estimate includes keys and nested payloads; it is not a process heap limit.
8
+ Idle entries expire without another request. Snapshot APIs retain their existing
9
+ 1,000-record page limit; an oversized page is returned without being cached.
10
+ The facade admits at most 32 computations, including invalidated work that has
11
+ not settled. Saturation uses the existing TypedRequest error envelope with
12
+ `code: METRICS_CAPACITY` and `retryAfterMs: 500`. Callers retain the last displayed
13
+ result and use their normal polling interval. Stop clears caches and awaits
14
+ admitted work; stopped-generation completions cannot repopulate a cache.
15
+
16
+ Authentication statistics share a separate one-second cache with eight keys
17
+ and eight active computations. Recording an event only changes memory and
18
+ invalidates derived results; it does not put database I/O on authentication's
19
+ recording path. Event arrival, actual flushes, pruning, database availability
20
+ changes, and reset invalidate the statistics cache. MetricsManager still owns
21
+ the final durable flush during shutdown.
22
+
23
+ AuthenticationEventDoc uses SmartData 11.9's bounded insert-if-absent batch API
24
+ so a flush retry preserves the complete first event body and timestamps. Window
25
+ statistics use one grouped count plus a sorted bounded page, both with five-second
26
+ server/client deadlines; both queries drain before the computation settles.
27
+ Buffered-ID checks and prune selection use bounded public offset pages. Pruning
28
+ deletes at most 500 selected identities per request behind the same cutoff.
29
+ The authentication and email-traffic model declarations use the exact index
30
+ names already owned by the monitoring migration; no stored-data or index rename
31
+ is required. SDK 16 accepts the same SmartData major for persisted admin accounts.
32
+
33
+ SmartData 8 and later omit undefined values on save. Explicit settings clears
34
+ therefore use model-owned atomic updates with `$unset`: staged DKIM promotion,
35
+ domain edge pins, final email-port settings, hub performance, edge public
36
+ identities, DNS provider errors, and VPN static-IP/VLAN settings. Reload tests
37
+ cover these transitions. Legacy stored nulls remain readable as absent optional
38
+ values. Omitted patch fields do not request removal.
39
+
40
+ Delegation probes coalesce by canonical zone and admit at most 32 owned DNS
41
+ clients. Every outcome awaits client termination. Failed termination retains
42
+ ownership so shutdown can retry and report failure; stop never silently forgets
43
+ a live helper. Probe clients use bounded DoH requests without fallback.
44
+
45
+ ## SmartDB 5.2.2 to 5.5.2 rollback qualification (2026-09-05)
46
+
47
+ `test/test.smartdb-rollback.node.ts` runs both published engine versions in
48
+ isolated processes through public SmartData and SmartDB maintenance APIs. It
49
+ checks a clean 5.2.2 checkpoint, a 5.5.2 write, clean reopening with 5.2.2, and
50
+ restoring the checkpoint followed by importing the post-upgrade canonical
51
+ export. Document/index digests must match, including the new write. This does
52
+ not qualify reopening an older engine after an unclean shutdown.
53
+
54
+ Application startup targets the latest actual schema migration, currently
55
+ 18.8.0, independently of the package version. Existing bridge-only ledgers from
56
+ 18.8.0 through the already released 18.9.3 remain at their recorded version;
57
+ unknown newer schemas fail closed. The rollback fixture seeds a production-like
58
+ 18.9.2 ledger and verifies that candidate startup preserves it and the old
59
+ 18.9.2 migration runner accepts the post-upgrade import. Advance the explicit
60
+ schema target only when adding a real migration.
61
+
62
+ Before a live upgrade, stop the owning process and verify its children exit,
63
+ then preserve the complete configured data tree and the active pnpm installation.
64
+ Never restore that checkpoint over mail/events accepted after the upgrade.
65
+ Preserve post-upgrade data with the qualified export/import procedure and
66
+ verify the canonical digest before reopening service. Restore storage-root
67
+ contents while preserving its bound directory inode. Check the live export
68
+ against the maintenance API's size limits before relying on that rollback path.
69
+
70
+ SmartDB 5.5.2 applies a leading aggregate match before its materialization
71
+ budget. A selective window can therefore remain below the query budget when
72
+ the complete collection exceeds 10,000 documents. This does not turn timestamp
73
+ range queries into indexed lookups or prove that production RSS will fall.
74
+
3
75
  ## Derived state must name its invalidation trigger (2026-07-25)
4
76
 
5
77
  Three separate production failures on one day had the same shape: **state was
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.9.3',
6
+ version: '18.9.4',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -21,8 +21,7 @@ import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
21
21
  // Import unified database
22
22
  import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc, CachedEmail } from './db/index.js';
23
23
  // Import migration runner and app version
24
- import { createMigrationRunner } from '../ts_migrations/index.js';
25
- import { commitinfo } from './00_commitinfo_data.js';
24
+ import { createApplicationMigrationRunner } from '../ts_migrations/index.js';
26
25
 
27
26
  import { OpsServer } from './opsserver/index.js';
28
27
  import { MetricsManager, OpsEventManager } from './monitoring/index.js';
@@ -1121,6 +1120,7 @@ export class DcRouter {
1121
1120
  }
1122
1121
 
1123
1122
  public async start() {
1123
+ this.authenticationEventManager.start();
1124
1124
  await this.checkSystemLimits();
1125
1125
  logger.log('info', 'Starting DcRouter Services');
1126
1126
  await this.serviceManager.start();
@@ -1273,7 +1273,7 @@ export class DcRouter {
1273
1273
 
1274
1274
  // Run any pending data migrations before anything else reads from the DB.
1275
1275
  // This must complete before ConfigManagers loads profiles.
1276
- const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version, {
1276
+ const migration = await createApplicationMigrationRunner(this.dcRouterDb.getDb(), {
1277
1277
  remoteIngressHubSettings: this.getRemoteIngressHubSettingsMigrationSeed(),
1278
1278
  emailServerSettings: this.getEmailSettingsMigrationSeed(),
1279
1279
  legacySmartMtaStoragePath: plugins.path.join(
@@ -1929,6 +1929,7 @@ export class DcRouter {
1929
1929
  // Stop accepting authenticated requests before MetricsManager performs its
1930
1930
  // final durable authentication-event and email-traffic drain.
1931
1931
  await this.stopOpsServer();
1932
+ await this.authenticationEventManager.stop();
1932
1933
 
1933
1934
  // Mail DNS reconciliation may legitimately outlive ServiceManager's
1934
1935
  // non-cancelling per-service timeout. Drain it before dependency teardown.
@@ -11,34 +11,24 @@ const DB_OPERATION_TIMEOUT_MS = 5_000;
11
11
 
12
12
  const getDb = () => DcRouterDb.getInstance().getDb();
13
13
 
14
- @plugins.smartdata.Collection(() => getDb())
15
14
  export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
16
15
  AuthenticationEventDoc,
17
16
  AuthenticationEventDoc
18
17
  > implements IAuthenticationEvent {
19
- @plugins.smartdata.unI()
20
- @plugins.smartdata.svDb()
21
18
  public id!: string;
22
19
 
23
- @plugins.smartdata.svDb()
24
20
  public timestamp!: number;
25
21
 
26
- @plugins.smartdata.svDb()
27
22
  public username!: string;
28
23
 
29
- @plugins.smartdata.svDb()
30
24
  public userId?: string;
31
25
 
32
- @plugins.smartdata.svDb()
33
26
  public success!: boolean;
34
27
 
35
- @plugins.smartdata.svDb()
36
28
  public requestedAuthSource!: TAuthenticationSource;
37
29
 
38
- @plugins.smartdata.svDb()
39
30
  public resolvedAuthSource?: TResolvedAuthenticationSource;
40
31
 
41
- @plugins.smartdata.svDb()
42
32
  public failureReason?: TAuthenticationFailureReason;
43
33
 
44
34
  constructor() {
@@ -58,17 +48,6 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
58
48
  };
59
49
  }
60
50
 
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
51
  private static toPersistedFields(eventArg: IAuthenticationEvent): IAuthenticationEvent {
73
52
  return {
74
53
  id: eventArg.id,
@@ -137,27 +116,19 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
137
116
  for (const event of eventsArg) {
138
117
  AuthenticationEventDoc.validateEvent(event);
139
118
  }
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
- );
119
+ const documents = new Map<string, AuthenticationEventDoc>();
120
+ for (const event of eventsArg) {
121
+ if (documents.has(event.id)) continue;
122
+ const timestamp = new Date(event.timestamp).toISOString();
123
+ documents.set(event.id, Object.assign(new AuthenticationEventDoc(), {
124
+ ...AuthenticationEventDoc.toPersistedFields(event),
125
+ _createdAt: timestamp,
126
+ _updatedAt: timestamp,
127
+ }));
128
+ }
129
+ await AuthenticationEventDoc.insertManyIfAbsent([...documents.values()], {
130
+ identityField: 'id', timeoutMS: DB_OPERATION_TIMEOUT_MS,
131
+ });
161
132
  }
162
133
 
163
134
  public static async getWindowSummary(
@@ -174,34 +145,31 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
174
145
  if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) {
175
146
  throw new Error('AuthenticationEventDoc.getWindowSummary requires a limit from 1 to 500');
176
147
  }
177
- const collection = await AuthenticationEventDoc.getNativeCollection();
148
+ await AuthenticationEventDoc.ensureInitialized();
178
149
  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 });
150
+ const [totalsResult, rowsResult] = await Promise.allSettled([
151
+ AuthenticationEventDoc.getGroupedTotals({
152
+ filter: selector, groupBy: ['success'], limit: 3,
153
+ maxTimeMS: DB_OPERATION_TIMEOUT_MS, timeoutMS: DB_OPERATION_TIMEOUT_MS,
154
+ }),
155
+ AuthenticationEventDoc.getOffsetPage(selector, {
156
+ skip: 0, limit: limitArg, sort: { timestamp: -1, id: -1 },
157
+ maxTimeMS: DB_OPERATION_TIMEOUT_MS, timeoutMS: DB_OPERATION_TIMEOUT_MS,
158
+ }),
159
+ ]);
160
+ // Both public queries own a cursor; drain both before releasing this computation.
161
+ if (totalsResult.status === 'rejected') throw totalsResult.reason;
162
+ if (rowsResult.status === 'rejected') throw rowsResult.reason;
163
+ const totals = totalsResult.value;
164
+ const rows = rowsResult.value;
165
+ let successes = 0;
166
+ let failures = 0;
167
+ for (const total of totals) {
168
+ if (total.group.success === true) successes = total.count;
169
+ else if (total.group.success === false) failures = total.count;
170
+ else throw new Error('Authentication events contain an invalid success value');
204
171
  }
172
+ return { successes, failures, events: rows.map((row) => row.toApiObject()) };
205
173
  }
206
174
 
207
175
  public static async findExistingIds(idsArg: string[]): Promise<Set<string>> {
@@ -210,34 +178,28 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
210
178
  if (ids.length > 500) {
211
179
  throw new Error('AuthenticationEventDoc.findExistingIds accepts at most 500 ids');
212
180
  }
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)));
181
+ await AuthenticationEventDoc.ensureInitialized();
182
+ const rows = await AuthenticationEventDoc.getOffsetPage({ id: { $in: ids } }, {
183
+ skip: 0, limit: ids.length, sort: { id: 1 }, projection: { id: 1, _id: 0 },
184
+ maxTimeMS: DB_OPERATION_TIMEOUT_MS, timeoutMS: DB_OPERATION_TIMEOUT_MS,
185
+ });
186
+ return new Set(rows.map((row) => row.id));
221
187
  }
222
188
 
223
189
  public static async pruneBefore(cutoffArg: number): Promise<number> {
224
190
  if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
225
191
  throw new Error('AuthenticationEventDoc.pruneBefore requires a valid cutoff');
226
192
  }
227
- const collection = await AuthenticationEventDoc.getNativeCollection();
193
+ await AuthenticationEventDoc.ensureInitialized();
228
194
  let deletedCount = 0;
229
195
  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();
196
+ const rows = await AuthenticationEventDoc.getOffsetPage({ timestamp: { $lt: cutoffArg } }, {
197
+ skip: 0, limit: 500, sort: { timestamp: 1, id: 1 }, projection: { id: 1, _id: 0 },
198
+ maxTimeMS: DB_OPERATION_TIMEOUT_MS, timeoutMS: DB_OPERATION_TIMEOUT_MS,
199
+ });
238
200
  if (rows.length === 0) break;
239
- const result = await collection.deleteMany(
240
- { _id: { $in: rows.map((row) => row._id) } },
201
+ const result = await AuthenticationEventDoc.atomicDeleteMany(
202
+ { timestamp: { $lt: cutoffArg }, $or: rows.map((row) => ({ id: row.id })) },
241
203
  { timeoutMS: DB_OPERATION_TIMEOUT_MS },
242
204
  );
243
205
  deletedCount += result.deletedCount;
@@ -246,3 +208,18 @@ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
246
208
  return deletedCount;
247
209
  }
248
210
  }
211
+
212
+ // Match the names installed by create-monitoring-event-indexes on existing hubs.
213
+ plugins.smartdata.defineCollectionModel(AuthenticationEventDoc, getDb, {
214
+ collectionName: 'AuthenticationEventDoc',
215
+ persistedFields: [
216
+ 'id', 'timestamp', 'username', 'userId', 'success',
217
+ 'requestedAuthSource', 'resolvedAuthSource', 'failureReason',
218
+ ],
219
+ identityFields: ['id'],
220
+ indexes: [
221
+ { name: 'authentication_event_id_unique', key: { id: 1 }, options: { unique: true } },
222
+ { name: 'authentication_event_timestamp', key: { timestamp: -1 } },
223
+ { name: 'authentication_event_success_timestamp', key: { success: 1, timestamp: -1 } },
224
+ ],
225
+ });
@@ -15,31 +15,22 @@ export interface IEmailTrafficBucketSnapshot extends IEmailTrafficBucketCounts {
15
15
  bucketStart: number;
16
16
  }
17
17
 
18
- @plugins.smartdata.Collection(() => getDb())
19
18
  export class EmailTrafficBucketDoc extends plugins.smartdata.SmartDataDbDoc<
20
19
  EmailTrafficBucketDoc,
21
20
  EmailTrafficBucketDoc
22
21
  > implements IEmailTrafficBucketSnapshot {
23
- @plugins.smartdata.unI()
24
- @plugins.smartdata.svDb()
25
22
  public id!: string;
26
23
 
27
- @plugins.smartdata.svDb()
28
24
  public bucketStart!: number;
29
25
 
30
- @plugins.smartdata.svDb()
31
26
  public sent: number = 0;
32
27
 
33
- @plugins.smartdata.svDb()
34
28
  public received: number = 0;
35
29
 
36
- @plugins.smartdata.svDb()
37
30
  public failed: number = 0;
38
31
 
39
- @plugins.smartdata.svDb()
40
32
  public createdAt!: number;
41
33
 
42
- @plugins.smartdata.svDb()
43
34
  public updatedAt!: number;
44
35
 
45
36
  constructor() {
@@ -159,3 +150,13 @@ export class EmailTrafficBucketDoc extends plugins.smartdata.SmartDataDbDoc<
159
150
  return deletedCount;
160
151
  }
161
152
  }
153
+
154
+ plugins.smartdata.defineCollectionModel(EmailTrafficBucketDoc, getDb, {
155
+ collectionName: 'EmailTrafficBucketDoc',
156
+ persistedFields: ['id', 'bucketStart', 'sent', 'received', 'failed', 'createdAt', 'updatedAt'],
157
+ identityFields: ['id'],
158
+ indexes: [
159
+ { name: 'email_traffic_bucket_id_unique', key: { id: 1 }, options: { unique: true } },
160
+ { name: 'email_traffic_bucket_start_unique', key: { bucketStart: 1 }, options: { unique: true } },
161
+ ],
162
+ });
@@ -99,6 +99,9 @@ export class DnsAuthorityManager {
99
99
  private updatedBy = '';
100
100
  private state: TDnsAuthorityState = 'unavailable';
101
101
  private reconciler?: TDnsAuthorityReconciler;
102
+ private stopped = false;
103
+ private activeProbes = new Map<string, Promise<IDnsDelegationProbe>>();
104
+ private ownedProbeClients = new Set<plugins.smartdns.dnsClientMod.Smartdns>();
102
105
 
103
106
  constructor(
104
107
  private getExpectedNameservers: () => string[],
@@ -109,6 +112,8 @@ export class DnsAuthorityManager {
109
112
  // ==========================================================================
110
113
 
111
114
  public async start(): Promise<void> {
115
+ if (this.ownedProbeClients.size > 0) throw new Error('DNS probe cleanup must complete before restart');
116
+ this.stopped = false;
112
117
  let doc: DnsAuthorityDoc | null;
113
118
  try {
114
119
  doc = await DnsAuthorityDoc.load();
@@ -154,9 +159,17 @@ export class DnsAuthorityManager {
154
159
  }
155
160
 
156
161
  public async stop(): Promise<void> {
162
+ this.stopped = true;
163
+ await Promise.allSettled(this.activeProbes.values());
164
+ const cleanup = await Promise.allSettled([...this.ownedProbeClients].map(async (client) => {
165
+ await client.terminate();
166
+ this.ownedProbeClients.delete(client);
167
+ }));
157
168
  this.verifiedZones = [];
158
169
  this.state = 'unavailable';
159
170
  this.reconciler = undefined;
171
+ const failures = cleanup.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
172
+ if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'DNS probe cleanup failed');
160
173
  }
161
174
 
162
175
  /**
@@ -223,7 +236,28 @@ export class DnsAuthorityManager {
223
236
  * that a zone is not ours; collapsing 'undeterminable' into 'not-delegated'
224
237
  * would let a transient resolver fault revoke authority.
225
238
  */
226
- public async probeDelegation(zoneArg: string): Promise<IDnsDelegationProbe> {
239
+ public probeDelegation(zoneArg: string): Promise<IDnsDelegationProbe> {
240
+ if (this.stopped) return Promise.reject(new Error('DNS authority manager is stopped'));
241
+ const zone = normalizeZone(zoneArg);
242
+ const existing = this.activeProbes.get(zone);
243
+ if (existing) return existing;
244
+ if (this.ownedProbeClients.size >= 32) return Promise.reject(new Error('DNS probe capacity reached'));
245
+ const run = this.probeDelegationUnlocked(zoneArg).finally(() => {
246
+ if (this.activeProbes.get(zone) === run) this.activeProbes.delete(zone);
247
+ });
248
+ this.activeProbes.set(zone, run);
249
+ return run;
250
+ }
251
+
252
+ private createProbeClient(): plugins.smartdns.dnsClientMod.Smartdns {
253
+ return new plugins.smartdns.dnsClientMod.Smartdns({
254
+ strategy: 'doh',
255
+ allowDohFallback: false,
256
+ timeoutMs: 10_000,
257
+ });
258
+ }
259
+
260
+ private async probeDelegationUnlocked(zoneArg: string): Promise<IDnsDelegationProbe> {
227
261
  const zone = normalizeZone(zoneArg);
228
262
  const expectedNameservers = this.getExpectedNameservers()
229
263
  .map(normalizeNameserver)
@@ -248,69 +282,72 @@ export class DnsAuthorityManager {
248
282
  };
249
283
  }
250
284
 
251
- const smartdnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
252
- strategy: 'doh',
253
- allowDohFallback: false,
254
- timeoutMs: 10_000,
255
- });
256
-
257
- let result: Awaited<ReturnType<typeof smartdnsClient.queryRecords>>;
285
+ const smartdnsClient = this.createProbeClient();
286
+ this.ownedProbeClients.add(smartdnsClient);
258
287
  try {
259
- result = await smartdnsClient.queryRecords(zone, 'NS');
260
- } catch (error: unknown) {
261
- return {
262
- zone,
263
- verdict: 'undeterminable',
264
- observedNameservers: [],
265
- expectedNameservers,
266
- detail: `delegation lookup threw: ${(error as Error).message}`,
267
- };
268
- }
269
288
 
270
- if (result.status === 'error') {
271
- return {
272
- zone,
273
- verdict: 'undeterminable',
274
- observedNameservers: [],
275
- expectedNameservers,
276
- detail: `delegation lookup failed (${result.error.kind}/${result.error.code}): ${result.error.message}`,
277
- };
278
- }
289
+ let result: Awaited<ReturnType<typeof smartdnsClient.queryRecords>>;
290
+ try {
291
+ result = await smartdnsClient.queryRecords(zone, 'NS');
292
+ } catch (error: unknown) {
293
+ return {
294
+ zone,
295
+ verdict: 'undeterminable',
296
+ observedNameservers: [],
297
+ expectedNameservers,
298
+ detail: `delegation lookup threw: ${(error as Error).message}`,
299
+ };
300
+ }
279
301
 
280
- if (result.status === 'missing') {
281
- return {
282
- zone,
283
- verdict: 'not-delegated',
284
- observedNameservers: [],
285
- expectedNameservers,
286
- detail: result.missingReason === 'nxdomain'
287
- ? `${zone} does not exist in public DNS`
288
- : `${zone} has no NS records in public DNS`,
289
- };
290
- }
302
+ if (result.status === 'error') {
303
+ return {
304
+ zone,
305
+ verdict: 'undeterminable',
306
+ observedNameservers: [],
307
+ expectedNameservers,
308
+ detail: `delegation lookup failed (${result.error.kind}/${result.error.code}): ${result.error.message}`,
309
+ };
310
+ }
311
+
312
+ if (result.status === 'missing') {
313
+ return {
314
+ zone,
315
+ verdict: 'not-delegated',
316
+ observedNameservers: [],
317
+ expectedNameservers,
318
+ detail: result.missingReason === 'nxdomain'
319
+ ? `${zone} does not exist in public DNS`
320
+ : `${zone} has no NS records in public DNS`,
321
+ };
322
+ }
291
323
 
292
- const observedNameservers: string[] = [...new Set<string>(
293
- result.records
294
- .map((record): string => normalizeNameserver(String(record.value ?? '')))
295
- .filter((nameserver): nameserver is string => Boolean(nameserver)),
296
- )];
297
- const matches = observedNameservers.filter((observed) => expectedNameservers.includes(observed));
298
- if (matches.length === 0) {
324
+ const observedNameservers: string[] = [...new Set<string>(
325
+ result.records
326
+ .map((record): string => normalizeNameserver(String(record.value ?? '')))
327
+ .filter((nameserver): nameserver is string => Boolean(nameserver)),
328
+ )];
329
+ const matches = observedNameservers.filter((observed) => expectedNameservers.includes(observed));
330
+ if (matches.length === 0) {
331
+ return {
332
+ zone,
333
+ verdict: 'not-delegated',
334
+ observedNameservers,
335
+ expectedNameservers,
336
+ detail: `${zone} is delegated to ${observedNameservers.join(', ') || 'nothing'}, which does not include any of our nameservers (${expectedNameservers.join(', ')})`,
337
+ };
338
+ }
299
339
  return {
300
340
  zone,
301
- verdict: 'not-delegated',
341
+ verdict: 'delegated',
302
342
  observedNameservers,
303
343
  expectedNameservers,
304
- detail: `${zone} is delegated to ${observedNameservers.join(', ') || 'nothing'}, which does not include any of our nameservers (${expectedNameservers.join(', ')})`,
344
+ detail: `${zone} is delegated to ${matches.join(', ')}`,
305
345
  };
346
+ } finally {
347
+ // Keep ownership if confirmed termination fails; stop() can retry it.
348
+ await smartdnsClient.terminate();
349
+ this.ownedProbeClients.delete(smartdnsClient);
306
350
  }
307
- return {
308
- zone,
309
- verdict: 'delegated',
310
- observedNameservers,
311
- expectedNameservers,
312
- detail: `${zone} is delegated to ${matches.join(', ')}`,
313
- };
314
351
  }
315
352
 
316
353
  // ==========================================================================
@@ -328,6 +365,7 @@ export class DnsAuthorityManager {
328
365
  }
329
366
 
330
367
  const probe = await this.probeDelegation(zone);
368
+ if (this.stopped) return { success: false, message: 'DNS authority manager is stopped', probe };
331
369
  if (probe.verdict !== 'delegated') {
332
370
  return {
333
371
  success: false,