@serve.zone/dcrouter 17.7.1 → 17.9.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 (94) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +712 -709
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +2 -0
  5. package/dist_ts/classes.dcrouter.js +23 -1
  6. package/dist_ts/config/classes.db-seeder.js +9 -1
  7. package/dist_ts/config/classes.gateway-client-manager.js +3 -1
  8. package/dist_ts/config/helpers.http-redirects.js +28 -1
  9. package/dist_ts/config/helpers.special-forwards.js +12 -9
  10. package/dist_ts/db/documents/classes.cached.email.d.ts +7 -0
  11. package/dist_ts/db/documents/classes.cached.email.js +52 -2
  12. package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
  13. package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
  14. package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
  15. package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
  16. package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
  17. package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
  18. package/dist_ts/db/documents/index.d.ts +3 -0
  19. package/dist_ts/db/documents/index.js +5 -1
  20. package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
  21. package/dist_ts/email/classes.accepted-email-spool.js +28 -3
  22. package/dist_ts/email/classes.email-route-builder.js +5 -1
  23. package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
  24. package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
  25. package/dist_ts/index.d.ts +1 -0
  26. package/dist_ts/index.js +3 -1
  27. package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
  28. package/dist_ts/opsserver/classes.opsserver.js +5 -1
  29. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +33 -6
  30. package/dist_ts/opsserver/handlers/index.d.ts +2 -0
  31. package/dist_ts/opsserver/handlers/index.js +3 -1
  32. package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
  33. package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
  34. package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
  35. package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
  36. package/dist_ts/plugins.d.ts +5 -2
  37. package/dist_ts/plugins.js +6 -3
  38. package/dist_ts/remoteingress/edge-derivation-routes.js +5 -1
  39. package/dist_ts/security/classes.route-policy-augmenter.d.ts +10 -6
  40. package/dist_ts/security/classes.route-policy-augmenter.js +65 -51
  41. package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
  42. package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
  43. package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
  44. package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
  45. package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
  46. package/dist_ts/webpush/classes.webpush-transport.js +161 -0
  47. package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
  48. package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
  49. package/dist_ts/webpush/index.d.ts +4 -0
  50. package/dist_ts/webpush/index.js +5 -0
  51. package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
  52. package/dist_ts/webpush/webpush-indexes.js +123 -0
  53. package/dist_ts_interfaces/data/remoteingress.d.ts +14 -2
  54. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  55. package/dist_ts_interfaces/data/route-management.js +1 -1
  56. package/dist_ts_migrations/index.js +285 -11
  57. package/dist_ts_web/00_commitinfo_data.js +1 -1
  58. package/dist_ts_web/elements/network/ops-view-routes.js +41 -1
  59. package/dist_ts_web/elements/network/special-forward-dialog.js +2 -2
  60. package/package.json +14 -10
  61. package/readme.md +27 -0
  62. package/third-party-notices.md +37 -0
  63. package/ts/00_commitinfo_data.ts +1 -1
  64. package/ts/classes.dcrouter.ts +29 -0
  65. package/ts/config/classes.db-seeder.ts +8 -0
  66. package/ts/config/classes.gateway-client-manager.ts +2 -0
  67. package/ts/config/helpers.http-redirects.ts +31 -1
  68. package/ts/config/helpers.special-forwards.ts +11 -8
  69. package/ts/db/documents/classes.cached.email.ts +47 -0
  70. package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
  71. package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
  72. package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
  73. package/ts/db/documents/index.ts +5 -0
  74. package/ts/email/classes.accepted-email-spool.ts +41 -2
  75. package/ts/email/classes.email-route-builder.ts +4 -0
  76. package/ts/email/classes.workapp-mail-manager.ts +97 -10
  77. package/ts/index.ts +3 -0
  78. package/ts/opsserver/classes.opsserver.ts +4 -0
  79. package/ts/opsserver/handlers/gatewayclient.handler.ts +34 -4
  80. package/ts/opsserver/handlers/index.ts +2 -0
  81. package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
  82. package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
  83. package/ts/plugins.ts +6 -0
  84. package/ts/remoteingress/edge-derivation-routes.ts +4 -0
  85. package/ts/security/classes.route-policy-augmenter.ts +70 -57
  86. package/ts/webpush/classes.webpush-crypto.ts +306 -0
  87. package/ts/webpush/classes.webpush-manager.ts +3173 -0
  88. package/ts/webpush/classes.webpush-transport.ts +241 -0
  89. package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
  90. package/ts/webpush/index.ts +4 -0
  91. package/ts/webpush/webpush-indexes.ts +141 -0
  92. package/ts_web/00_commitinfo_data.ts +1 -1
  93. package/ts_web/elements/network/ops-view-routes.ts +38 -0
  94. package/ts_web/elements/network/special-forward-dialog.ts +1 -1
@@ -40,6 +40,7 @@ import { GatewayRouteDnsReconciler } from './dns/classes.gateway-route-dns-recon
40
40
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
41
41
  import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js';
42
42
  import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
43
+ import { WebPushManager } from './webpush/index.js';
43
44
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
44
45
  import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailOutboundMode, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
45
46
  import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
@@ -356,6 +357,7 @@ export class DcRouter {
356
357
  public dnsServerRuntime: DnsServerRuntime;
357
358
  public emailRouteBuilder: EmailRouteBuilder;
358
359
  public routePolicyAugmenter: RoutePolicyAugmenter;
360
+ public webPushManager: WebPushManager;
359
361
 
360
362
  // TypedRouter for API endpoints
361
363
  public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -399,6 +401,9 @@ export class DcRouter {
399
401
  this.dnsServerRuntime = new DnsServerRuntime(this);
400
402
  this.emailRouteBuilder = new EmailRouteBuilder(this);
401
403
  this.routePolicyAugmenter = new RoutePolicyAugmenter(this);
404
+ // Construct before OpsServer so early handlers can report unavailable safely.
405
+ // Secret reads remain deferred until WebPushProvider.start().
406
+ this.webPushManager = new WebPushManager();
402
407
 
403
408
  // Initialize service manager and register all services
404
409
  this.serviceManager = new plugins.taskbuffer.ServiceManager({
@@ -452,6 +457,24 @@ export class DcRouter {
452
457
  );
453
458
  }
454
459
 
460
+ // Web Push provider: disabled by default. Enabled mode is critical and
461
+ // depends on the migrated database; disabled mode reads no provider secrets.
462
+ const webPushDependencies = this.options.dbConfig?.enabled === false
463
+ ? []
464
+ : ['DcRouterDb'];
465
+ this.serviceManager.addService(
466
+ new plugins.taskbuffer.Service('WebPushProvider')
467
+ .critical()
468
+ .dependsOn(...webPushDependencies)
469
+ .withStart(async () => {
470
+ await this.webPushManager.start();
471
+ })
472
+ .withStop(async () => {
473
+ await this.webPushManager.stop();
474
+ })
475
+ .withRetry({ maxRetries: 0 }),
476
+ );
477
+
455
478
  // MetricsManager: optional, depends on OpsServer
456
479
  this.serviceManager.addService(
457
480
  new plugins.taskbuffer.Service('MetricsManager')
@@ -1048,6 +1071,12 @@ export class DcRouter {
1048
1071
  logger.log('info', `Remote Ingress: tunnel port=${remoteIngressHubSettings.tunnelPort}, edges=${edgeCount} registered/${connectedCount} connected`);
1049
1072
  }
1050
1073
 
1074
+ // Web Push provider summary
1075
+ logger.log(
1076
+ 'info',
1077
+ `Web Push Provider: ${this.webPushManager.isReady ? 'ready' : this.webPushManager.isEnabled ? 'unavailable' : 'disabled'}`,
1078
+ );
1079
+
1051
1080
  // Database summary
1052
1081
  if (this.dcRouterDb) {
1053
1082
  logger.log('info', `Database: ${this.dcRouterDb.isEmbedded() ? 'embedded' : 'external'}, db=${this.dcRouterDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.dbConfig?.cleanupIntervalHours || 1)}h interval)`);
@@ -102,6 +102,14 @@ const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
102
102
  },
103
103
  },
104
104
  },
105
+ {
106
+ name: 'LOCAL NETWORK',
107
+ description: 'RFC1918, IPv6 ULA, and loopback sources for direct hub and private control-plane routes',
108
+ security: {
109
+ ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8', 'fc00::/7', '::1'],
110
+ maxConnections: 1000,
111
+ },
112
+ },
105
113
  {
106
114
  name: 'STANDARD',
107
115
  description: 'Standard internal access with common private subnets',
@@ -33,6 +33,8 @@ const defaultCapabilities: IGatewayClient['capabilities'] = {
33
33
  manageMail: false,
34
34
  readCertificates: false,
35
35
  requestCertificates: false,
36
+ readWebPush: false,
37
+ manageWebPush: false,
36
38
  };
37
39
 
38
40
  export class GatewayClientManager {
@@ -3,7 +3,7 @@ import {
3
3
  canonicalizeSourceRouteId,
4
4
  type IHttpRedirectInfo,
5
5
  } from '../../ts_interfaces/data/route-management.js';
6
- import type { IDcRouterRouteConfig, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
6
+ import type { IDcRouterRouteConfig, IRouteIngress, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
7
7
 
8
8
  const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect';
9
9
  const REDIRECT_STATUS_CODE = 301;
@@ -18,6 +18,7 @@ interface IRedirectCandidate {
18
18
  pathPattern?: string;
19
19
  sourceRouteNames: Set<string>;
20
20
  sourceRouteIds: Set<string>;
21
+ ingress: IRouteIngress;
21
22
  remoteIngress?: IRouteRemoteIngress;
22
23
  }
23
24
 
@@ -97,6 +98,7 @@ function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): I
97
98
  if (existing) {
98
99
  existing.sourceRouteNames.add(getRouteDisplayName(route));
99
100
  if (route.id) existing.sourceRouteIds.add(canonicalizeSourceRouteId(route.id));
101
+ existing.ingress = mergeIngress(existing.ingress, getRouteIngress(route as IDcRouterRouteConfig));
100
102
  existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress);
101
103
  continue;
102
104
  }
@@ -109,6 +111,7 @@ function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): I
109
111
  pathPattern: route.match.path,
110
112
  sourceRouteNames: new Set([getRouteDisplayName(route)]),
111
113
  sourceRouteIds: new Set(route.id ? [canonicalizeSourceRouteId(route.id)] : []),
114
+ ingress: getRouteIngress(route as IDcRouterRouteConfig),
112
115
  remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress),
113
116
  });
114
117
  }
@@ -301,10 +304,37 @@ function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRout
301
304
  type: 'socket-handler',
302
305
  socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE),
303
306
  },
307
+ ingress: candidate.ingress,
304
308
  ...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}),
305
309
  };
306
310
  }
307
311
 
312
+ function getRouteIngress(route: IDcRouterRouteConfig): IRouteIngress {
313
+ if (route.ingress) {
314
+ return {
315
+ directHub: route.ingress.directHub,
316
+ smartVpn: route.ingress.smartVpn,
317
+ };
318
+ }
319
+ if (route.vpnOnly) {
320
+ return {
321
+ directHub: false,
322
+ smartVpn: true,
323
+ };
324
+ }
325
+ return {
326
+ directHub: true,
327
+ smartVpn: true,
328
+ };
329
+ }
330
+
331
+ function mergeIngress(current: IRouteIngress, next: IRouteIngress): IRouteIngress {
332
+ return {
333
+ directHub: current.directHub || next.directHub,
334
+ smartVpn: current.smartVpn || next.smartVpn,
335
+ };
336
+ }
337
+
308
338
  function mergeRemoteIngress(
309
339
  current: IRouteRemoteIngress | undefined,
310
340
  next: IRouteRemoteIngress | undefined,
@@ -31,6 +31,9 @@ export function buildLetsEncryptHttp01Route(
31
31
  const edgeFilter = configArg.remoteIngress?.edgeFilter
32
32
  ?.map((entry) => entry.trim())
33
33
  .filter(Boolean);
34
+ if (configArg.remoteIngress?.enabled !== true) {
35
+ throw new Error('Public HTTP-01 forwards require RemoteIngress');
36
+ }
34
37
 
35
38
  return {
36
39
  name,
@@ -48,14 +51,14 @@ export function buildLetsEncryptHttp01Route(
48
51
  type: 'forward',
49
52
  targets: [target],
50
53
  },
51
- ...(configArg.remoteIngress?.enabled
52
- ? {
53
- remoteIngress: {
54
- enabled: true,
55
- ...(edgeFilter?.length ? { edgeFilter } : {}),
56
- },
57
- }
58
- : {}),
54
+ ingress: {
55
+ directHub: false,
56
+ smartVpn: false,
57
+ },
58
+ remoteIngress: {
59
+ enabled: true,
60
+ ...(edgeFilter?.length ? { edgeFilter } : {}),
61
+ },
59
62
  };
60
63
  }
61
64
 
@@ -130,6 +130,18 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
130
130
  @plugins.smartdata.svDb()
131
131
  public rawContentSize: number = 0;
132
132
 
133
+ /** Resolved outbound identity id that scopes a service-mail replay key. */
134
+ @plugins.smartdata.svDb()
135
+ public submissionCredentialId?: string;
136
+
137
+ /** Caller-supplied replay key. It must remain absent for non-idempotent mail. */
138
+ @plugins.smartdata.svDb()
139
+ public submissionIdempotencyKey?: string;
140
+
141
+ /** Digest of the exact authenticated outbound submission semantics. */
142
+ @plugins.smartdata.svDb()
143
+ public submissionDigest?: string;
144
+
133
145
  /** Bounded durable SMTP transaction history copied from SmartMTA. */
134
146
  @plugins.smartdata.svDb()
135
147
  public smtpTransactions: ICachedEmailSmtpTransaction[] = [];
@@ -242,12 +254,47 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
242
254
 
243
255
  public override async createSavableObject(): Promise<CachedEmail> {
244
256
  const savableObject = await super.createSavableObject();
257
+ const submissionFields = [
258
+ this.submissionCredentialId,
259
+ this.submissionIdempotencyKey,
260
+ this.submissionDigest,
261
+ ];
262
+ if (submissionFields.every((valueArg) => valueArg === undefined)) {
263
+ delete savableObject.submissionCredentialId;
264
+ delete savableObject.submissionIdempotencyKey;
265
+ delete savableObject.submissionDigest;
266
+ } else if (
267
+ typeof this.submissionCredentialId !== 'string'
268
+ || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(this.submissionCredentialId)
269
+ || typeof this.submissionIdempotencyKey !== 'string'
270
+ || !/^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/.test(this.submissionIdempotencyKey)
271
+ || typeof this.submissionDigest !== 'string'
272
+ || !/^sha256:[a-f0-9]{64}$/.test(this.submissionDigest)
273
+ ) {
274
+ throw new Error('CachedEmail submission idempotency metadata is invalid');
275
+ }
245
276
  if (this.rawContentObjectKey) {
246
277
  delete savableObject.rawContent;
247
278
  }
248
279
  return savableObject;
249
280
  }
250
281
 
282
+ public static async findBySubmissionIdempotencyKey(
283
+ credentialIdArg: string,
284
+ idempotencyKeyArg: string,
285
+ ): Promise<CachedEmail | null> {
286
+ if (
287
+ typeof credentialIdArg !== 'string'
288
+ || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(credentialIdArg)
289
+ || typeof idempotencyKeyArg !== 'string'
290
+ || !/^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/.test(idempotencyKeyArg)
291
+ ) throw new Error('CachedEmail submission idempotency selector is invalid');
292
+ return await CachedEmail.getInstance({
293
+ submissionCredentialId: credentialIdArg,
294
+ submissionIdempotencyKey: idempotencyKeyArg,
295
+ });
296
+ }
297
+
251
298
  /**
252
299
  * Create a new CachedEmail with a unique ID
253
300
  */
@@ -0,0 +1,48 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+
4
+ const getDb = () => DcRouterDb.getInstance().getDb();
5
+
6
+ /**
7
+ * Durable, binding-scoped Web Push admission counters.
8
+ *
9
+ * A credential rotation must not reset these counters, so the stable binding
10
+ * id and lifecycle generation are the authority rather than a credential id.
11
+ */
12
+ @plugins.smartdata.Collection(() => getDb())
13
+ export class WebPushAdmissionDoc extends plugins.smartdata.SmartDataDbDoc<
14
+ WebPushAdmissionDoc,
15
+ WebPushAdmissionDoc
16
+ > {
17
+ @plugins.smartdata.svDb()
18
+ public bindingId!: string;
19
+
20
+ @plugins.smartdata.svDb()
21
+ public lifecycleGeneration!: number;
22
+
23
+ @plugins.smartdata.svDb()
24
+ public shortWindowStartedAt!: number;
25
+
26
+ @plugins.smartdata.svDb()
27
+ public shortWindowCount!: number;
28
+
29
+ @plugins.smartdata.svDb()
30
+ public longWindowStartedAt!: number;
31
+
32
+ @plugins.smartdata.svDb()
33
+ public longWindowCount!: number;
34
+
35
+ @plugins.smartdata.svDb()
36
+ public revision!: number;
37
+
38
+ @plugins.smartdata.svDb()
39
+ public updatedAt!: number;
40
+
41
+ @plugins.smartdata.svDb()
42
+ public purgeAt!: Date;
43
+
44
+ public static getNativeCollection():
45
+ plugins.smartdata.SmartdataCollection<WebPushAdmissionDoc>['mongoDbCollection'] {
46
+ return getDb().mongoDb.collection('WebPushAdmissionDoc');
47
+ }
48
+ }
@@ -0,0 +1,147 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+ import type { IWebPushEncryptedEnvelope } from '../../webpush/classes.webpush-crypto.js';
4
+
5
+ export interface IWebPushStoredCredential {
6
+ id: string;
7
+ status: plugins.servezoneInterfaces.data.TWebPushCredentialStatus;
8
+ hmacKeyId: string;
9
+ secretHash?: string;
10
+ createdAt: number;
11
+ updatedAt: number;
12
+ lastRotatedAt: number | null;
13
+ validUntil: number | null;
14
+ }
15
+
16
+ export interface IWebPushStoredVapidKey {
17
+ id: string;
18
+ publicKey: string;
19
+ status: plugins.servezoneInterfaces.data.TWebPushVapidKeyStatus;
20
+ createdAt: number;
21
+ activatedAt: number | null;
22
+ retireAfter: number | null;
23
+ retiredAt: number | null;
24
+ privateKeyEnvelope?: IWebPushEncryptedEnvelope;
25
+ }
26
+
27
+ export interface IWebPushCredentialRotationRecovery {
28
+ requestedCredentialId: string;
29
+ resultingCredentialId: string;
30
+ lifecycleGeneration: number;
31
+ secretEnvelope: IWebPushEncryptedEnvelope;
32
+ createdAt: number;
33
+ expiresAt: number;
34
+ }
35
+
36
+ export type TWebPushControllerIntent = 'enabled' | 'disabled' | 'deleted';
37
+
38
+ export interface IWebPushStoredControllerFence {
39
+ id: string;
40
+ epoch: string;
41
+ generation: number;
42
+ operationId: string;
43
+ intent: TWebPushControllerIntent;
44
+ requestDigest: string;
45
+ appliedAt: number;
46
+ }
47
+
48
+ const getDb = () => DcRouterDb.getInstance().getDb();
49
+
50
+ @plugins.smartdata.Collection(() => getDb())
51
+ export class WebPushBindingDoc extends plugins.smartdata.SmartDataDbDoc<
52
+ WebPushBindingDoc,
53
+ WebPushBindingDoc
54
+ > {
55
+ @plugins.smartdata.svDb()
56
+ public id!: string;
57
+
58
+ @plugins.smartdata.svDb()
59
+ public ownerGatewayClientType!: plugins.servezoneInterfaces.data.TGatewayClientType;
60
+
61
+ @plugins.smartdata.svDb()
62
+ public ownerGatewayClientId!: string;
63
+
64
+ @plugins.smartdata.svDb()
65
+ public ownerAppInstanceId!: string;
66
+
67
+ @plugins.smartdata.svDb()
68
+ public enabled!: boolean;
69
+
70
+ @plugins.smartdata.svDb()
71
+ public status!: plugins.servezoneInterfaces.data.TWebPushBindingStatus;
72
+
73
+ @plugins.smartdata.svDb()
74
+ public credential!: IWebPushStoredCredential;
75
+
76
+ /** At most one still-valid overlap credential; expired entries are scrubbed. */
77
+ @plugins.smartdata.svDb()
78
+ public previousCredentials: IWebPushStoredCredential[] = [];
79
+
80
+ /** MUST BE ABSENT outside the bounded idempotent credential-rotation replay window. */
81
+ @plugins.smartdata.svDb()
82
+ public credentialRotationRecovery?: IWebPushCredentialRotationRecovery;
83
+
84
+ @plugins.smartdata.svDb()
85
+ public vapidKeys: IWebPushStoredVapidKey[] = [];
86
+
87
+ @plugins.smartdata.svDb()
88
+ public createdAt!: number;
89
+
90
+ @plugins.smartdata.svDb()
91
+ public updatedAt!: number;
92
+
93
+ @plugins.smartdata.svDb()
94
+ public createdBy!: string;
95
+
96
+ @plugins.smartdata.svDb()
97
+ public mutationRevision!: number;
98
+
99
+ @plugins.smartdata.svDb()
100
+ public lifecycleGeneration!: number;
101
+
102
+ /** Highest durably applied controller mutation for this owner-reserving binding. */
103
+ @plugins.smartdata.svDb()
104
+ public controllerFence?: IWebPushStoredControllerFence;
105
+
106
+ /** MUST BE ABSENT unless this owner-reserving binding is a deletion tombstone. */
107
+ @plugins.smartdata.svDb()
108
+ public deletedAt?: number;
109
+
110
+ public static getNativeCollection():
111
+ plugins.smartdata.SmartdataCollection<WebPushBindingDoc>['mongoDbCollection'] {
112
+ return getDb().mongoDb.collection('WebPushBindingDoc');
113
+ }
114
+
115
+ public override async createSavableObject(): Promise<WebPushBindingDoc> {
116
+ const savable = await super.createSavableObject();
117
+ if (savable.credential.secretHash === undefined) {
118
+ delete savable.credential.secretHash;
119
+ }
120
+ savable.previousCredentials = (savable.previousCredentials || []).map((credentialArg) => ({
121
+ ...credentialArg,
122
+ lastRotatedAt: credentialArg.lastRotatedAt ?? null,
123
+ validUntil: credentialArg.validUntil ?? null,
124
+ })).map((credentialArg) => {
125
+ if (credentialArg.secretHash === undefined) delete credentialArg.secretHash;
126
+ return credentialArg;
127
+ });
128
+ if (savable.credentialRotationRecovery === undefined) {
129
+ delete savable.credentialRotationRecovery;
130
+ }
131
+ if (savable.controllerFence === undefined) delete savable.controllerFence;
132
+ savable.vapidKeys = (savable.vapidKeys || []).map((keyArg) => {
133
+ const normalized = {
134
+ ...keyArg,
135
+ activatedAt: keyArg.activatedAt ?? null,
136
+ retireAfter: keyArg.retireAfter ?? null,
137
+ retiredAt: keyArg.retiredAt ?? null,
138
+ };
139
+ if (normalized.privateKeyEnvelope === undefined) {
140
+ delete normalized.privateKeyEnvelope;
141
+ }
142
+ return normalized;
143
+ });
144
+ if (savable.deletedAt === undefined) delete savable.deletedAt;
145
+ return savable;
146
+ }
147
+ }
@@ -0,0 +1,136 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+ import type { IWebPushEncryptedEnvelope } from '../../webpush/classes.webpush-crypto.js';
4
+
5
+ const getDb = () => DcRouterDb.getInstance().getDb();
6
+
7
+ @plugins.smartdata.Collection(() => getDb())
8
+ export class WebPushSpoolDoc extends plugins.smartdata.SmartDataDbDoc<
9
+ WebPushSpoolDoc,
10
+ WebPushSpoolDoc
11
+ > {
12
+ @plugins.smartdata.svDb()
13
+ public id!: string;
14
+
15
+ @plugins.smartdata.svDb()
16
+ public schemaVersion: 1 = 1;
17
+
18
+ @plugins.smartdata.svDb()
19
+ public bindingId!: string;
20
+
21
+ @plugins.smartdata.svDb()
22
+ public lifecycleGeneration!: number;
23
+
24
+ /** Credential that created this item; used only for the idempotency namespace. */
25
+ @plugins.smartdata.svDb()
26
+ public credentialId!: string;
27
+
28
+ @plugins.smartdata.svDb()
29
+ public hmacKeyId!: string;
30
+
31
+ @plugins.smartdata.svDb()
32
+ public idempotencyKeyDigest!: string;
33
+
34
+ @plugins.smartdata.svDb()
35
+ public requestDigest!: string;
36
+
37
+ @plugins.smartdata.svDb()
38
+ public subscriptionId!: string;
39
+
40
+ @plugins.smartdata.svDb()
41
+ public endpointDigest!: string;
42
+
43
+ @plugins.smartdata.svDb()
44
+ public vapidKeyId!: string;
45
+
46
+ @plugins.smartdata.svDb()
47
+ public subscriptionEnvelope?: IWebPushEncryptedEnvelope;
48
+
49
+ @plugins.smartdata.svDb()
50
+ public payloadEnvelope?: IWebPushEncryptedEnvelope;
51
+
52
+ @plugins.smartdata.svDb()
53
+ public payloadBytes!: number;
54
+
55
+ @plugins.smartdata.svDb()
56
+ public ttlSeconds!: number;
57
+
58
+ @plugins.smartdata.svDb()
59
+ public urgency!: plugins.servezoneInterfaces.data.TWebPushUrgency;
60
+
61
+ @plugins.smartdata.svDb()
62
+ public topic: string | null = null;
63
+
64
+ @plugins.smartdata.svDb()
65
+ public state!: plugins.servezoneInterfaces.data.TWebPushDeliveryState;
66
+
67
+ @plugins.smartdata.svDb()
68
+ public attempts: number = 0;
69
+
70
+ @plugins.smartdata.svDb()
71
+ public acceptedAt!: number;
72
+
73
+ @plugins.smartdata.svDb()
74
+ public updatedAt!: number;
75
+
76
+ @plugins.smartdata.svDb()
77
+ public nextAttemptAt!: number;
78
+
79
+ @plugins.smartdata.svDb()
80
+ public expiresAt!: number;
81
+
82
+ /** MUST BE ABSENT while the item is not leased. */
83
+ @plugins.smartdata.svDb()
84
+ public leaseToken?: string;
85
+
86
+ /** MUST BE ABSENT while the item is not leased. */
87
+ @plugins.smartdata.svDb()
88
+ public leaseExpiresAt?: number;
89
+
90
+ /** MUST BE ABSENT until cancellation races with an in-flight send. */
91
+ @plugins.smartdata.svDb()
92
+ public cancelRequestedAt?: number;
93
+
94
+ /** MUST BE ABSENT until an in-flight cancellation records its terminal reason. */
95
+ @plugins.smartdata.svDb()
96
+ public cancelErrorCode?: string;
97
+
98
+ /** MUST BE ABSENT before a terminal transition. */
99
+ @plugins.smartdata.svDb()
100
+ public terminalAt?: number;
101
+
102
+ @plugins.smartdata.svDb()
103
+ public pushServiceStatusCode: number | null = null;
104
+
105
+ @plugins.smartdata.svDb()
106
+ public errorCode: string | null = null;
107
+
108
+ /** MUST BE ABSENT until terminal retention begins. */
109
+ @plugins.smartdata.svDb()
110
+ public purgeAt?: Date;
111
+
112
+ public static getNativeCollection():
113
+ plugins.smartdata.SmartdataCollection<WebPushSpoolDoc>['mongoDbCollection'] {
114
+ return getDb().mongoDb.collection('WebPushSpoolDoc');
115
+ }
116
+
117
+ public override async createSavableObject(): Promise<WebPushSpoolDoc> {
118
+ const savable = await super.createSavableObject();
119
+ for (const field of [
120
+ 'subscriptionEnvelope',
121
+ 'payloadEnvelope',
122
+ 'leaseToken',
123
+ 'leaseExpiresAt',
124
+ 'cancelRequestedAt',
125
+ 'cancelErrorCode',
126
+ 'terminalAt',
127
+ 'purgeAt',
128
+ ] as const) {
129
+ if (savable[field] === undefined) delete savable[field];
130
+ }
131
+ savable.topic = savable.topic ?? null;
132
+ savable.pushServiceStatusCode = savable.pushServiceStatusCode ?? null;
133
+ savable.errorCode = savable.errorCode ?? null;
134
+ return savable;
135
+ }
136
+ }
@@ -41,5 +41,10 @@ export * from './classes.acme-config.doc.js';
41
41
  // Email domain management
42
42
  export * from './classes.email-domain.doc.js';
43
43
  export * from './classes.email-server-settings.doc.js';
44
+
45
+ // Web Push provider documents
46
+ export * from './classes.webpush-binding.doc.js';
47
+ export * from './classes.webpush-admission.doc.js';
48
+ export * from './classes.webpush-spool.doc.js';
44
49
  export * from './classes.smartmta-storage.doc.js';
45
50
  export * from './classes.ops-config-event.doc.js';
@@ -145,6 +145,10 @@ export interface IAcceptRawEmailOptions {
145
145
  processAfterAccept?: boolean;
146
146
  /** Explicit direction override for programmatic submitters. Defaults to the session-authenticated rule. */
147
147
  direction?: TCachedEmailDirection;
148
+ /** Exact authenticated outbound identity and replay metadata. */
149
+ submissionCredentialId?: string;
150
+ submissionIdempotencyKey?: string;
151
+ submissionDigest?: string;
148
152
  }
149
153
 
150
154
  /**
@@ -221,7 +225,7 @@ export class AcceptedEmailSpool {
221
225
  await cachedEmail.save();
222
226
  await this.notifyEmailQueuePersisted(cachedEmail, 'email-accepted');
223
227
  } catch (error) {
224
- await this.deleteRawMessage(cachedEmail).catch(() => undefined);
228
+ await this.cleanupRawMessageAfterSaveFailure(cachedEmail, error);
225
229
  throw error;
226
230
  }
227
231
  if (context.abortSignal?.aborted) {
@@ -262,6 +266,9 @@ export class AcceptedEmailSpool {
262
266
  cachedEmail.cc = [];
263
267
  cachedEmail.bcc = [];
264
268
  cachedEmail.subject = optionsArg.subject || this.extractHeader(rawMessage, 'subject') || '';
269
+ cachedEmail.submissionCredentialId = optionsArg.submissionCredentialId;
270
+ cachedEmail.submissionIdempotencyKey = optionsArg.submissionIdempotencyKey;
271
+ cachedEmail.submissionDigest = optionsArg.submissionDigest;
265
272
  const persistedRawMessage = this.setDcRouterCacheIdHeader(rawMessage, cachedEmail.id);
266
273
  await this.persistRawMessage(cachedEmail, persistedRawMessage);
267
274
  cachedEmail.status = 'pending';
@@ -287,7 +294,7 @@ export class AcceptedEmailSpool {
287
294
  await cachedEmail.save();
288
295
  await this.notifyEmailQueuePersisted(cachedEmail, 'raw-email-accepted');
289
296
  } catch (error) {
290
- await this.deleteRawMessage(cachedEmail).catch(() => undefined);
297
+ await this.cleanupRawMessageAfterSaveFailure(cachedEmail, error);
291
298
  throw error;
292
299
  }
293
300
 
@@ -1019,6 +1026,38 @@ export class AcceptedEmailSpool {
1019
1026
  await blobStorage.delete(cachedEmailArg.rawContentObjectKey);
1020
1027
  }
1021
1028
 
1029
+ private async cleanupRawMessageAfterSaveFailure(
1030
+ cachedEmailArg: CachedEmail,
1031
+ saveErrorArg: unknown,
1032
+ ): Promise<void> {
1033
+ try {
1034
+ await this.deleteRawMessage(cachedEmailArg);
1035
+ return;
1036
+ } catch (deleteError: unknown) {
1037
+ // Keep a durable, immediately-expired reference. CacheCleaner retries the
1038
+ // SmartBucket deletion before removing this recovery document.
1039
+ cachedEmailArg.submissionCredentialId = undefined;
1040
+ cachedEmailArg.submissionIdempotencyKey = undefined;
1041
+ cachedEmailArg.submissionDigest = undefined;
1042
+ cachedEmailArg.markFailed(
1043
+ `Accepted-email persistence failed; raw-message cleanup pending: ${(saveErrorArg as Error).message}`,
1044
+ );
1045
+ cachedEmailArg.setTTL(0);
1046
+ try {
1047
+ await cachedEmailArg.save();
1048
+ } catch (recoveryError: unknown) {
1049
+ throw new AggregateError(
1050
+ [saveErrorArg, deleteError, recoveryError],
1051
+ `Accepted-email persistence and durable raw-message cleanup failed for ${cachedEmailArg.id}`,
1052
+ );
1053
+ }
1054
+ logger.log(
1055
+ 'warn',
1056
+ `Persisted raw-message cleanup recovery for ${cachedEmailArg.id}: ${(deleteError as Error).message}`,
1057
+ );
1058
+ }
1059
+ }
1060
+
1022
1061
  private async persistRawMessage(cachedEmailArg: CachedEmail, rawMessageArg: string): Promise<void> {
1023
1062
  const blobStorage = this.dcRouterRef.getSmartMtaBlobStorageManager();
1024
1063
  if (!blobStorage) {