@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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +712 -709
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -0
- package/dist_ts/classes.dcrouter.js +23 -1
- package/dist_ts/config/classes.db-seeder.js +9 -1
- package/dist_ts/config/classes.gateway-client-manager.js +3 -1
- package/dist_ts/config/helpers.http-redirects.js +28 -1
- package/dist_ts/config/helpers.special-forwards.js +12 -9
- package/dist_ts/db/documents/classes.cached.email.d.ts +7 -0
- package/dist_ts/db/documents/classes.cached.email.js +52 -2
- package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
- package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
- package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
- package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
- package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
- package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
- package/dist_ts/db/documents/index.d.ts +3 -0
- package/dist_ts/db/documents/index.js +5 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
- package/dist_ts/email/classes.accepted-email-spool.js +28 -3
- package/dist_ts/email/classes.email-route-builder.js +5 -1
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
- package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
- package/dist_ts/opsserver/classes.opsserver.js +5 -1
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +33 -6
- package/dist_ts/opsserver/handlers/index.d.ts +2 -0
- package/dist_ts/opsserver/handlers/index.js +3 -1
- package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
- package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
- package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
- package/dist_ts/plugins.d.ts +5 -2
- package/dist_ts/plugins.js +6 -3
- package/dist_ts/remoteingress/edge-derivation-routes.js +5 -1
- package/dist_ts/security/classes.route-policy-augmenter.d.ts +10 -6
- package/dist_ts/security/classes.route-policy-augmenter.js +65 -51
- package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
- package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
- package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
- package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
- package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
- package/dist_ts/webpush/classes.webpush-transport.js +161 -0
- package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
- package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
- package/dist_ts/webpush/index.d.ts +4 -0
- package/dist_ts/webpush/index.js +5 -0
- package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
- package/dist_ts/webpush/webpush-indexes.js +123 -0
- package/dist_ts_interfaces/data/remoteingress.d.ts +14 -2
- package/dist_ts_interfaces/data/route-management.d.ts +2 -0
- package/dist_ts_interfaces/data/route-management.js +1 -1
- package/dist_ts_migrations/index.js +285 -11
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/network/ops-view-routes.js +41 -1
- package/dist_ts_web/elements/network/special-forward-dialog.js +2 -2
- package/package.json +14 -10
- package/readme.md +27 -0
- package/third-party-notices.md +37 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +29 -0
- package/ts/config/classes.db-seeder.ts +8 -0
- package/ts/config/classes.gateway-client-manager.ts +2 -0
- package/ts/config/helpers.http-redirects.ts +31 -1
- package/ts/config/helpers.special-forwards.ts +11 -8
- package/ts/db/documents/classes.cached.email.ts +47 -0
- package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
- package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
- package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
- package/ts/db/documents/index.ts +5 -0
- package/ts/email/classes.accepted-email-spool.ts +41 -2
- package/ts/email/classes.email-route-builder.ts +4 -0
- package/ts/email/classes.workapp-mail-manager.ts +97 -10
- package/ts/index.ts +3 -0
- package/ts/opsserver/classes.opsserver.ts +4 -0
- package/ts/opsserver/handlers/gatewayclient.handler.ts +34 -4
- package/ts/opsserver/handlers/index.ts +2 -0
- package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
- package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
- package/ts/plugins.ts +6 -0
- package/ts/remoteingress/edge-derivation-routes.ts +4 -0
- package/ts/security/classes.route-policy-augmenter.ts +70 -57
- package/ts/webpush/classes.webpush-crypto.ts +306 -0
- package/ts/webpush/classes.webpush-manager.ts +3173 -0
- package/ts/webpush/classes.webpush-transport.ts +241 -0
- package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
- package/ts/webpush/index.ts +4 -0
- package/ts/webpush/webpush-indexes.ts +141 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/network/ops-view-routes.ts +38 -0
- package/ts_web/elements/network/special-forward-dialog.ts +1 -1
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -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',
|
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
}
|
package/ts/db/documents/index.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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) {
|