@serve.zone/dcrouter 17.7.0 → 17.8.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 (79) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +410 -410
  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.gateway-client-manager.js +3 -1
  7. package/dist_ts/db/documents/classes.cached.email.d.ts +16 -1
  8. package/dist_ts/db/documents/classes.cached.email.js +67 -4
  9. package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
  10. package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
  11. package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
  12. package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
  13. package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
  14. package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
  15. package/dist_ts/db/documents/index.d.ts +3 -0
  16. package/dist_ts/db/documents/index.js +5 -1
  17. package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
  18. package/dist_ts/email/classes.accepted-email-spool.js +31 -3
  19. package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
  20. package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
  21. package/dist_ts/index.d.ts +1 -0
  22. package/dist_ts/index.js +3 -1
  23. package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
  24. package/dist_ts/opsserver/classes.opsserver.js +5 -1
  25. package/dist_ts/opsserver/handlers/email-ops.handler.js +5 -3
  26. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +11 -5
  27. package/dist_ts/opsserver/handlers/index.d.ts +2 -0
  28. package/dist_ts/opsserver/handlers/index.js +3 -1
  29. package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
  30. package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
  31. package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
  32. package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
  33. package/dist_ts/plugins.d.ts +5 -2
  34. package/dist_ts/plugins.js +6 -3
  35. package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
  36. package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
  37. package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
  38. package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
  39. package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
  40. package/dist_ts/webpush/classes.webpush-transport.js +161 -0
  41. package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
  42. package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
  43. package/dist_ts/webpush/index.d.ts +4 -0
  44. package/dist_ts/webpush/index.js +5 -0
  45. package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
  46. package/dist_ts/webpush/webpush-indexes.js +123 -0
  47. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  48. package/dist_ts_interfaces/data/route-management.js +1 -1
  49. package/dist_ts_migrations/index.js +221 -11
  50. package/dist_ts_web/00_commitinfo_data.js +1 -1
  51. package/package.json +13 -9
  52. package/readme.md +27 -0
  53. package/third-party-notices.md +37 -0
  54. package/ts/00_commitinfo_data.ts +1 -1
  55. package/ts/classes.dcrouter.ts +29 -0
  56. package/ts/config/classes.gateway-client-manager.ts +2 -0
  57. package/ts/db/documents/classes.cached.email.ts +58 -1
  58. package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
  59. package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
  60. package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
  61. package/ts/db/documents/index.ts +5 -0
  62. package/ts/email/classes.accepted-email-spool.ts +44 -2
  63. package/ts/email/classes.workapp-mail-manager.ts +97 -10
  64. package/ts/index.ts +3 -0
  65. package/ts/opsserver/classes.opsserver.ts +4 -0
  66. package/ts/opsserver/handlers/email-ops.handler.ts +4 -2
  67. package/ts/opsserver/handlers/gatewayclient.handler.ts +10 -3
  68. package/ts/opsserver/handlers/index.ts +2 -0
  69. package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
  70. package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
  71. package/ts/plugins.ts +6 -0
  72. package/ts/webpush/classes.webpush-crypto.ts +306 -0
  73. package/ts/webpush/classes.webpush-manager.ts +3173 -0
  74. package/ts/webpush/classes.webpush-transport.ts +241 -0
  75. package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
  76. package/ts/webpush/index.ts +4 -0
  77. package/ts/webpush/webpush-indexes.ts +141 -0
  78. package/ts_web/00_commitinfo_data.ts +1 -1
  79. package/readme.plan.md +0 -22
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.7.0',
6
+ version: '17.8.0',
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": "17.7.0",
4
+ "version": "17.8.0",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -16,13 +16,14 @@
16
16
  "license": "MIT",
17
17
  "devDependencies": {
18
18
  "@git.zone/tsbuild": "^4.4.2",
19
- "@git.zone/tsbundle": "^2.11.2",
19
+ "@git.zone/tsbundle": "^2.11.3",
20
20
  "@git.zone/tsdeno": "^1.7.0",
21
- "@git.zone/tsdocker": "^3.0.0",
21
+ "@git.zone/tsdocker": "^3.1.0",
22
22
  "@git.zone/tsrun": "^2.0.5",
23
23
  "@git.zone/tstest": "^3.6.6",
24
24
  "@git.zone/tswatch": "^3.3.5",
25
- "@types/node": "26.1.1"
25
+ "@types/node": "26.1.1",
26
+ "@types/web-push": "3.6.4"
26
27
  },
27
28
  "dependencies": {
28
29
  "@api.global/typedrequest": "^3.3.2",
@@ -46,7 +47,7 @@
46
47
  "@push.rocks/smartguard": "^3.1.2",
47
48
  "@push.rocks/smartjwt": "^2.5.1",
48
49
  "@push.rocks/smartlog": "^3.2.2",
49
- "@push.rocks/smartmcp": "^0.2.0",
50
+ "@push.rocks/smartmcp": "^0.3.0",
50
51
  "@push.rocks/smartmetrics": "^3.0.3",
51
52
  "@push.rocks/smartmigration": "1.4.1",
52
53
  "@push.rocks/smartmta": "^8.2.1",
@@ -63,13 +64,15 @@
63
64
  "@push.rocks/smartvpn": "1.21.0",
64
65
  "@push.rocks/taskbuffer": "^8.0.2",
65
66
  "@serve.zone/catalog": "^2.19.0",
66
- "@serve.zone/interfaces": "^17.1.0",
67
- "@serve.zone/remoteingress": "^5.0.1",
67
+ "@serve.zone/interfaces": "^18.0.0",
68
+ "@serve.zone/remoteingress": "^5.0.3",
68
69
  "@tsclass/tsclass": "^9.5.1",
69
70
  "@types/qrcode": "^1.5.6",
71
+ "ipaddr.js": "^2.4.0",
70
72
  "lru-cache": "^11.5.2",
71
73
  "qrcode": "^1.5.4",
72
- "uuid": "^14.0.1"
74
+ "uuid": "^14.0.1",
75
+ "web-push": "3.6.7"
73
76
  },
74
77
  "keywords": [
75
78
  "mail service",
@@ -113,7 +116,8 @@
113
116
  "deno.json",
114
117
  "tsconfig.json",
115
118
  ".smartconfig.json",
116
- "readme.md"
119
+ "readme.md",
120
+ "third-party-notices.md"
117
121
  ],
118
122
  "engines": {
119
123
  "node": ">=22"
package/readme.md CHANGED
@@ -136,6 +136,33 @@ Important runtime behavior:
136
136
  - Email listener ports can be remapped internally, for example public `25`, `587`, and `465` to unprivileged internal ports.
137
137
  - `emailOutboundMode: 'remoteIngress'` requires an enabled RemoteIngress hub and an eligible connected QUIC edge with SMTP egress enabled. If no eligible edge is available, outbound delivery fails or defers instead of silently falling back to direct SMTP from the hub.
138
138
 
139
+ ## Web Push Provider
140
+
141
+ Web Push is disabled by default and does not read its secret configuration while disabled. It requires DB-backed persistence and the `17.8.0` migration. Enable it only after all three secret values are present:
142
+
143
+ | Environment variable | Value |
144
+ | --- | --- |
145
+ | `DCROUTER_WEB_PUSH_ENABLED` | `true`, `1`, `on`, or `yes` starts the provider and worker. `false`, `0`, `off`, `no`, unset, or empty disables it. Any other value fails startup. |
146
+ | `DCROUTER_WEB_PUSH_MASTER_KEY_RING` | JSON or a `base64Object:` JSON value containing AES-256 keys used for encrypted VAPID and queued-delivery envelopes. |
147
+ | `DCROUTER_WEB_PUSH_HMAC_KEY_RING` | The same key-ring shape, with different key material, used for credentials and opaque request/endpoint digests. |
148
+ | `DCROUTER_WEB_PUSH_VAPID_SUBJECT` | An HTTPS URL or `mailto:` contact passed to push services. |
149
+
150
+ Each key ring has the form `{"currentKeyId":"key-2026-07","keys":{"key-2026-07":"<32-byte-unpadded-base64url>"}}`, supports at most eight distinct keys, and must name a configured current key. Encryption and HMAC rings must never reuse key material.
151
+
152
+ Operational behavior:
153
+
154
+ - Control-plane callers need gateway-client `readWebPush` or `manageWebPush` capability. App calls use the one-time binding credential returned by `syncWebPushBinding`; the active credential record stores only its HMAC verifier. To recover a lost rotation response, dcrouter may return the same issuance again during its 15-minute overlap window; that recovery secret is AES-GCM encrypted at rest and is removed when the window expires.
155
+ - Every sync or delete carries a durable controller id, stable epoch, monotonically increasing generation, operation id, and intent. Sync accepts only `enabled`; delete accepts `disabled` or `deleted`, an exact owner, and an optional binding id. Exact same-generation retries resume cleanup and replay the durable outcome, while lower generations, changed controller epochs, or conflicting same-generation requests fail closed. An owner-reserving delete tombstone is created even when a timed-out create has not returned a binding id, so the late create cannot reactivate the provider.
156
+ - A binding admits 120 new requests per minute and 5,000 per hour, atomically across workers and restarts. Matching idempotent replays do not consume admission. The nonterminal queue is capped at 10,000 items per binding.
157
+ - Payloads are limited to 3,500 UTF-8 bytes, TTL must be from 1 through 86,400 seconds, delivery makes at most eight attempts, and each retry sends the remaining TTL. Terminal rows retain only non-secret delivery metadata for seven days.
158
+ - Subscription endpoints must be public HTTPS hostnames on port 443. DNS is bounded and pinned for the request; IP literals, credentials, fragments, and any answer containing a non-global address are rejected.
159
+ - App credential rotation has a 15-minute overlap. VAPID rotation retains retiring keys for 90 days and, if necessary, until old queued items have terminally drained, with four VAPID keys maximum. Rotate before reaching that bound.
160
+ - For a key-ring rotation, add new unique key material, set `currentKeyId`, restart, and keep old encryption keys until every VAPID, delivery, and credential-recovery envelope using them has been rotated or purged. Keep each old HMAC key only until no current or overlap credential references its key id; terminal and idempotency retention does not require the old HMAC key. Remove old keys only after those conditions are true.
161
+ - `deleteWebPushBinding` is a destructive tombstone operation: it revokes and removes all credential hashes, prior credentials, credential-recovery envelopes, and encrypted VAPID private keys; cancels and scrubs queued work; and clears admission state. Queued work is terminalized immediately, but an already sending request remains `sending` until the remote response wins or its lease expires. An expired lease after cancellation is recorded as a failed delivery with an unknown remote outcome, never as a confirmed cancellation. Delete/disable reports success only after that lifecycle drains; a bounded drain timeout returns a retryable failure, and the same controller operation resumes cleanup. Re-enabling the same owner creates a new lifecycle, credential, and VAPID key. Clients must recreate browser push subscriptions and must not reuse deleted credentials.
162
+ - The worker performs bounded maintenance every cycle and at startup, repeatedly removing expired credential overlap/recovery state, drained retiring or retired VAPID private keys, stale admission state, and incomplete disabled/deleted lifecycle cleanup without materializing an unbounded collection.
163
+
164
+ For a staged rollout, deploy with `DCROUTER_WEB_PUSH_ENABLED=false`, confirm the migration and health of the rest of dcrouter, install the three secret values, enable one gateway-client binding, and exercise status/enqueue/delivery/cancellation before expanding. To roll back worker activity, set the feature flag to `false` and restart; durable unexpired items remain encrypted and resume after re-enable. Do not delete bindings as a rollback mechanism because deletion is intentionally irreversible.
165
+
139
166
  ## Route Ownership
140
167
 
141
168
  dcrouter keeps generated and operator-created routes separate so automation can reconcile safely.
@@ -0,0 +1,37 @@
1
+ # Third-Party Notices
2
+
3
+ ## web-push
4
+
5
+ `web-push` is used under the Mozilla Public License 2.0.
6
+
7
+ Copyright 2015 Marco Castelluccio
8
+
9
+ This Source Code Form is subject to the terms of the Mozilla Public License,
10
+ v. 2.0. If a copy of the MPL was not distributed with this file, you can
11
+ obtain one at <https://mozilla.org/MPL/2.0/>.
12
+
13
+ Source: <https://github.com/web-push-libs/web-push>
14
+
15
+ ## http_ece
16
+
17
+ `http_ece` is used transitively through `web-push`.
18
+
19
+ Copyright (c) 2015 Martin Thomson
20
+
21
+ Permission is hereby granted, free of charge, to any person obtaining a copy
22
+ of this software and associated documentation files (the "Software"), to deal
23
+ in the Software without restriction, including without limitation the rights
24
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
25
+ copies of the Software, and to permit persons to whom the Software is
26
+ furnished to do so, subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in all
29
+ copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
+ SOFTWARE.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.7.0',
6
+ version: '17.8.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -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)`);
@@ -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 {
@@ -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[] = [];
@@ -162,11 +174,21 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
162
174
  public maxAttempts: number = 3;
163
175
 
164
176
  /**
165
- * Timestamp for next delivery attempt
177
+ * Timestamp for next delivery attempt.
178
+ * For SmartMTA-managed deliveries this is a RECOVERY LEASE (SmartMTA's retry
179
+ * time + grace), used by the spool to detect a silently dead queue — it is
180
+ * NOT the user-facing retry schedule; that lives in smartMtaNextAttempt.
166
181
  */
167
182
  @plugins.smartdata.svDb()
168
183
  public nextAttempt!: Date;
169
184
 
185
+ /**
186
+ * SmartMTA's actual next scheduled retry (display value for the ops UI).
187
+ * Only set while the delivery is deferred inside the SmartMTA queue.
188
+ */
189
+ @plugins.smartdata.svDb()
190
+ public smartMtaNextAttempt?: Date;
191
+
170
192
  /**
171
193
  * Last error message if delivery failed
172
194
  */
@@ -232,12 +254,47 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
232
254
 
233
255
  public override async createSavableObject(): Promise<CachedEmail> {
234
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
+ }
235
276
  if (this.rawContentObjectKey) {
236
277
  delete savableObject.rawContent;
237
278
  }
238
279
  return savableObject;
239
280
  }
240
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
+
241
298
  /**
242
299
  * Create a new CachedEmail with a unique ID
243
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';