@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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +410 -410
- 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.gateway-client-manager.js +3 -1
- package/dist_ts/db/documents/classes.cached.email.d.ts +16 -1
- package/dist_ts/db/documents/classes.cached.email.js +67 -4
- 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 +31 -3
- 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/email-ops.handler.js +5 -3
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +11 -5
- 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/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/route-management.d.ts +2 -0
- package/dist_ts_interfaces/data/route-management.js +1 -1
- package/dist_ts_migrations/index.js +221 -11
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +13 -9
- 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.gateway-client-manager.ts +2 -0
- package/ts/db/documents/classes.cached.email.ts +58 -1
- 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 +44 -2
- 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/email-ops.handler.ts +4 -2
- package/ts/opsserver/handlers/gatewayclient.handler.ts +10 -3
- 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/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/readme.plan.md +0 -22
|
@@ -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
|
|
|
@@ -875,9 +882,11 @@ export class AcceptedEmailSpool {
|
|
|
875
882
|
cachedEmail.status = 'deferred';
|
|
876
883
|
cachedEmail.lastError = item.lastError || 'SmartMTA delivery deferred';
|
|
877
884
|
const smartMtaNextAttempt = item.nextAttempt || new Date(Date.now() + ACCEPTED_EMAIL_RETRY_DELAY_MS);
|
|
885
|
+
cachedEmail.smartMtaNextAttempt = smartMtaNextAttempt;
|
|
878
886
|
cachedEmail.nextAttempt = new Date(smartMtaNextAttempt.getTime() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
879
887
|
} else {
|
|
880
888
|
cachedEmail.status = 'queued';
|
|
889
|
+
cachedEmail.smartMtaNextAttempt = undefined;
|
|
881
890
|
cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
882
891
|
}
|
|
883
892
|
await cachedEmail.save();
|
|
@@ -954,6 +963,7 @@ export class AcceptedEmailSpool {
|
|
|
954
963
|
}
|
|
955
964
|
this.updateSmartMtaRouteData(cachedEmail, queueItem, status);
|
|
956
965
|
const smartMtaNextAttempt = queueItem.nextAttempt || new Date(Date.now() + ACCEPTED_EMAIL_RETRY_DELAY_MS);
|
|
966
|
+
cachedEmail.smartMtaNextAttempt = status === 'deferred' ? smartMtaNextAttempt : undefined;
|
|
957
967
|
cachedEmail.nextAttempt = new Date(smartMtaNextAttempt.getTime() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
|
|
958
968
|
await cachedEmail.save();
|
|
959
969
|
await this.notifyEmailQueuePersisted(cachedEmail, 'email-live-queue-postponed');
|
|
@@ -1016,6 +1026,38 @@ export class AcceptedEmailSpool {
|
|
|
1016
1026
|
await blobStorage.delete(cachedEmailArg.rawContentObjectKey);
|
|
1017
1027
|
}
|
|
1018
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
|
+
|
|
1019
1061
|
private async persistRawMessage(cachedEmailArg: CachedEmail, rawMessageArg: string): Promise<void> {
|
|
1020
1062
|
const blobStorage = this.dcRouterRef.getSmartMtaBlobStorageManager();
|
|
1021
1063
|
if (!blobStorage) {
|
|
@@ -383,6 +383,7 @@ export class WorkAppMailManager {
|
|
|
383
383
|
authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
|
|
384
384
|
messageArg: plugins.servezoneInterfaces.data.IMailOutboundMessagePayload,
|
|
385
385
|
outboundIdentityIdArg?: string,
|
|
386
|
+
idempotencyKeyArg?: string,
|
|
386
387
|
): Promise<TMailEnqueueResponse> {
|
|
387
388
|
if (!this.dcRouterRef.emailServer) {
|
|
388
389
|
return { accepted: false, message: 'Email server is not running' };
|
|
@@ -394,19 +395,41 @@ export class WorkAppMailManager {
|
|
|
394
395
|
outboundIdentityIdArg,
|
|
395
396
|
);
|
|
396
397
|
await this.assertIdentityOutboundReady(identity);
|
|
398
|
+
const idempotencyKey = this.normalizeMailIdempotencyKey(idempotencyKeyArg);
|
|
399
|
+
const submissionDigest = idempotencyKey
|
|
400
|
+
? this.getMailSubmissionDigest(identity, messageArg)
|
|
401
|
+
: undefined;
|
|
402
|
+
const existingReplay = idempotencyKey
|
|
403
|
+
? await this.getMailSubmissionReplay(identity, idempotencyKey, submissionDigest!)
|
|
404
|
+
: undefined;
|
|
405
|
+
if (existingReplay) return existingReplay;
|
|
397
406
|
const rawMessage = this.buildOutboundRawMessage(identity, messageArg);
|
|
398
407
|
const recipients = this.getMessageRecipients(messageArg);
|
|
399
408
|
const session = this.buildAuthenticatedSession(identity, messageArg.from, recipients, rawMessage);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
409
|
+
let acceptance;
|
|
410
|
+
try {
|
|
411
|
+
acceptance = await this.dcRouterRef.acceptedEmailSpool.acceptRawMessage({
|
|
412
|
+
rawMessage,
|
|
413
|
+
envelope: {
|
|
414
|
+
mailFrom: identity.address,
|
|
415
|
+
rcptTo: recipients,
|
|
416
|
+
},
|
|
417
|
+
session,
|
|
418
|
+
messageId: this.extractHeader(rawMessage, 'message-id'),
|
|
419
|
+
subject: messageArg.subject,
|
|
420
|
+
...(idempotencyKey ? {
|
|
421
|
+
submissionCredentialId: identity.id,
|
|
422
|
+
submissionIdempotencyKey: idempotencyKey,
|
|
423
|
+
submissionDigest,
|
|
424
|
+
} : {}),
|
|
425
|
+
});
|
|
426
|
+
} catch (errorArg) {
|
|
427
|
+
const racedReplay = idempotencyKey
|
|
428
|
+
? await this.getMailSubmissionReplay(identity, idempotencyKey, submissionDigest!)
|
|
429
|
+
: undefined;
|
|
430
|
+
if (racedReplay) return racedReplay;
|
|
431
|
+
throw errorArg;
|
|
432
|
+
}
|
|
410
433
|
return {
|
|
411
434
|
accepted: acceptance.accepted,
|
|
412
435
|
spoolItemId: acceptance.spoolItemId,
|
|
@@ -421,6 +444,70 @@ export class WorkAppMailManager {
|
|
|
421
444
|
}
|
|
422
445
|
}
|
|
423
446
|
|
|
447
|
+
private normalizeMailIdempotencyKey(valueArg?: string): string | undefined {
|
|
448
|
+
if (valueArg === undefined) return undefined;
|
|
449
|
+
if (
|
|
450
|
+
typeof valueArg !== 'string'
|
|
451
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/.test(valueArg)
|
|
452
|
+
) throw new Error('Mail idempotencyKey is invalid');
|
|
453
|
+
return valueArg;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private canonicalMailSubmissionJson(valueArg: unknown): string {
|
|
457
|
+
if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'boolean') {
|
|
458
|
+
return JSON.stringify(valueArg);
|
|
459
|
+
}
|
|
460
|
+
if (typeof valueArg === 'number') {
|
|
461
|
+
if (!Number.isFinite(valueArg)) throw new Error('Mail submission contains an invalid number');
|
|
462
|
+
return JSON.stringify(valueArg);
|
|
463
|
+
}
|
|
464
|
+
if (Array.isArray(valueArg)) {
|
|
465
|
+
return `[${valueArg.map((itemArg) => this.canonicalMailSubmissionJson(itemArg)).join(',')}]`;
|
|
466
|
+
}
|
|
467
|
+
if (valueArg && typeof valueArg === 'object') {
|
|
468
|
+
const entries = Object.entries(valueArg as Record<string, unknown>)
|
|
469
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
470
|
+
.sort(([leftKey], [rightKey]) => leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0);
|
|
471
|
+
return `{${entries.map(([keyArg, entryValue]) => (
|
|
472
|
+
`${JSON.stringify(keyArg)}:${this.canonicalMailSubmissionJson(entryValue)}`
|
|
473
|
+
)).join(',')}}`;
|
|
474
|
+
}
|
|
475
|
+
throw new Error('Mail submission contains an unsupported value');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private getMailSubmissionDigest(
|
|
479
|
+
identityArg: IStoredWorkAppMailIdentity,
|
|
480
|
+
messageArg: plugins.servezoneInterfaces.data.IMailOutboundMessagePayload,
|
|
481
|
+
): string {
|
|
482
|
+
const canonical = this.canonicalMailSubmissionJson({
|
|
483
|
+
identityId: identityArg.id,
|
|
484
|
+
message: messageArg,
|
|
485
|
+
version: 1,
|
|
486
|
+
});
|
|
487
|
+
return `sha256:${plugins.crypto.createHash('sha256').update(canonical, 'utf8').digest('hex')}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async getMailSubmissionReplay(
|
|
491
|
+
identityArg: IStoredWorkAppMailIdentity,
|
|
492
|
+
idempotencyKeyArg: string,
|
|
493
|
+
submissionDigestArg: string,
|
|
494
|
+
): Promise<TMailEnqueueResponse | undefined> {
|
|
495
|
+
const existing = await CachedEmail.findBySubmissionIdempotencyKey(
|
|
496
|
+
identityArg.id,
|
|
497
|
+
idempotencyKeyArg,
|
|
498
|
+
);
|
|
499
|
+
if (!existing) return undefined;
|
|
500
|
+
if (existing.submissionDigest !== submissionDigestArg) {
|
|
501
|
+
throw new Error('Mail idempotencyKey was reused with different message semantics');
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
accepted: true,
|
|
505
|
+
spoolItemId: existing.id,
|
|
506
|
+
outboundIdentity: this.toMailOutboundIdentity(identityArg),
|
|
507
|
+
message: 'Idempotent service-mail replay',
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
424
511
|
public async getMailDeliveryStatus(
|
|
425
512
|
spoolItemIdArg: string,
|
|
426
513
|
): Promise<TMailDeliveryStatusResponse> {
|
package/ts/index.ts
CHANGED
|
@@ -15,6 +15,9 @@ export * from './radius/index.js';
|
|
|
15
15
|
// Remote Ingress module
|
|
16
16
|
export * from './remoteingress/index.js';
|
|
17
17
|
|
|
18
|
+
// Web Push provider module
|
|
19
|
+
export * from './webpush/index.js';
|
|
20
|
+
|
|
18
21
|
// HTTP/3 module
|
|
19
22
|
export type { IHttp3Config } from './http3/index.js';
|
|
20
23
|
|
|
@@ -41,6 +41,8 @@ export class OpsServer {
|
|
|
41
41
|
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
|
42
42
|
private emailDomainHandler!: handlers.EmailDomainHandler;
|
|
43
43
|
private gatewayClientHandler!: handlers.GatewayClientHandler;
|
|
44
|
+
private webPushControlHandler!: handlers.WebPushControlHandler;
|
|
45
|
+
private webPushAppHandler!: handlers.WebPushAppHandler;
|
|
44
46
|
private configEventHandler!: handlers.ConfigEventHandler;
|
|
45
47
|
private smtpRealtimeUnsubscribe?: () => void;
|
|
46
48
|
private emailQueueRealtimeUnsubscribe?: () => void;
|
|
@@ -106,6 +108,8 @@ export class OpsServer {
|
|
|
106
108
|
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
|
107
109
|
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
|
108
110
|
this.gatewayClientHandler = new handlers.GatewayClientHandler(this);
|
|
111
|
+
this.webPushControlHandler = new handlers.WebPushControlHandler(this);
|
|
112
|
+
this.webPushAppHandler = new handlers.WebPushAppHandler(this);
|
|
109
113
|
this.configEventHandler = new handlers.ConfigEventHandler(this);
|
|
110
114
|
this.smtpRealtimeUnsubscribe =
|
|
111
115
|
this.dcRouterRef.acceptedEmailSpool?.onSmtpTransactionPersisted?.((eventArg) => {
|
|
@@ -180,11 +180,13 @@ export class EmailOpsHandler {
|
|
|
180
180
|
const { headers, body } = this.parseRawEmail(rawContent);
|
|
181
181
|
const session = this.parseCachedEmailSession(doc);
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
// Display SmartMTA's true retry schedule — doc.nextAttempt is the spool's
|
|
184
|
+
// recovery lease (retry time + 30min grace), not the user-facing countdown.
|
|
185
|
+
const nextAttemptMs = doc.smartMtaNextAttempt ? new Date(doc.smartMtaNextAttempt).getTime() : NaN;
|
|
184
186
|
return {
|
|
185
187
|
...base,
|
|
186
188
|
smtpTransactions: structuredClone(doc.smtpTransactions || []),
|
|
187
|
-
...(
|
|
189
|
+
...(base.status === 'deferred' && Number.isFinite(nextAttemptMs) && nextAttemptMs > 0
|
|
188
190
|
? {
|
|
189
191
|
nextAttemptAt: nextAttemptMs,
|
|
190
192
|
nextAttemptNumber: (doc.attempts || 0) + 1,
|
|
@@ -524,6 +524,7 @@ export class GatewayClientHandler {
|
|
|
524
524
|
dataArg.auth || {},
|
|
525
525
|
dataArg.message,
|
|
526
526
|
dataArg.outboundIdentityId,
|
|
527
|
+
dataArg.idempotencyKey,
|
|
527
528
|
);
|
|
528
529
|
},
|
|
529
530
|
),
|
|
@@ -609,6 +610,12 @@ export class GatewayClientHandler {
|
|
|
609
610
|
http3: {
|
|
610
611
|
enabled: dcRouter.options.http3?.enabled !== false,
|
|
611
612
|
},
|
|
613
|
+
webPush: {
|
|
614
|
+
bindings: Boolean(dcRouter.webPushManager?.isReady),
|
|
615
|
+
delivery: Boolean(dcRouter.webPushManager?.isReady),
|
|
616
|
+
cancellation: Boolean(dcRouter.webPushManager?.isReady),
|
|
617
|
+
vapidRotation: Boolean(dcRouter.webPushManager?.isReady),
|
|
618
|
+
},
|
|
612
619
|
};
|
|
613
620
|
}
|
|
614
621
|
|
|
@@ -1346,9 +1353,9 @@ export class GatewayClientHandler {
|
|
|
1346
1353
|
if (!sourceBindings) {
|
|
1347
1354
|
return {
|
|
1348
1355
|
success: false,
|
|
1349
|
-
message: sourceProfileRef
|
|
1356
|
+
message: sourceProfileRef?.trim()
|
|
1350
1357
|
? `source profile '${sourceProfileRef}' not found`
|
|
1351
|
-
: '
|
|
1358
|
+
: 'STANDARD source profile not found',
|
|
1352
1359
|
};
|
|
1353
1360
|
}
|
|
1354
1361
|
|
|
@@ -1747,7 +1754,7 @@ export class GatewayClientHandler {
|
|
|
1747
1754
|
): interfaces.data.IRouteSourceBinding[] | undefined {
|
|
1748
1755
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
|
1749
1756
|
const profiles = resolver?.listProfiles() || [];
|
|
1750
|
-
const wantedRef = (requestedProfileRefArg || '
|
|
1757
|
+
const wantedRef = (requestedProfileRefArg?.trim() || 'standard').toLowerCase();
|
|
1751
1758
|
const profile = profiles.find((profileArg: interfaces.data.ISourceProfile) => {
|
|
1752
1759
|
return profileArg.id.trim().toLowerCase() === wantedRef;
|
|
1753
1760
|
}) || profiles.find((profileArg: interfaces.data.ISourceProfile) => {
|
|
@@ -23,3 +23,5 @@ export * from './email-domain.handler.js';
|
|
|
23
23
|
export * from './gatewayclient.handler.js';
|
|
24
24
|
export * from './realtime.handler.js';
|
|
25
25
|
export * from './config-event.handler.js';
|
|
26
|
+
export * from './webpush-control.handler.js';
|
|
27
|
+
export * from './webpush-app.handler.js';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import type { OpsServer } from '../classes.opsserver.js';
|
|
3
|
+
|
|
4
|
+
export class WebPushAppHandler {
|
|
5
|
+
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
6
|
+
|
|
7
|
+
public constructor(private readonly opsServerRef: OpsServer) {
|
|
8
|
+
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
9
|
+
this.registerHandlers();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private registerHandlers(): void {
|
|
13
|
+
this.typedrouter.addTypedHandler(
|
|
14
|
+
new plugins.typedrequest.TypedHandler<
|
|
15
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_GetWebPushServiceStatus
|
|
16
|
+
>(
|
|
17
|
+
'getWebPushServiceStatus',
|
|
18
|
+
async (dataArg) => {
|
|
19
|
+
const manager = this.opsServerRef.dcRouterRef.webPushManager;
|
|
20
|
+
if (!manager.isReady) return manager.getUnavailableServiceStatus();
|
|
21
|
+
return await manager.getServiceStatus(dataArg.auth);
|
|
22
|
+
},
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
this.typedrouter.addTypedHandler(
|
|
27
|
+
new plugins.typedrequest.TypedHandler<
|
|
28
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_EnqueueWebPush
|
|
29
|
+
>(
|
|
30
|
+
'enqueueWebPush',
|
|
31
|
+
async (dataArg) => {
|
|
32
|
+
try {
|
|
33
|
+
return await this.opsServerRef.dcRouterRef.webPushManager.enqueue(
|
|
34
|
+
dataArg.auth,
|
|
35
|
+
{
|
|
36
|
+
idempotencyKey: dataArg.idempotencyKey,
|
|
37
|
+
subscriptionId: dataArg.subscriptionId,
|
|
38
|
+
subscription: dataArg.subscription,
|
|
39
|
+
vapidKeyId: dataArg.vapidKeyId,
|
|
40
|
+
payload: dataArg.payload,
|
|
41
|
+
ttlSeconds: dataArg.ttlSeconds,
|
|
42
|
+
urgency: dataArg.urgency,
|
|
43
|
+
collapseKey: dataArg.collapseKey,
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
} catch (error: unknown) {
|
|
47
|
+
return {
|
|
48
|
+
accepted: false,
|
|
49
|
+
message: this.safeMessage(error),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
this.typedrouter.addTypedHandler(
|
|
57
|
+
new plugins.typedrequest.TypedHandler<
|
|
58
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_CancelWebPush
|
|
59
|
+
>(
|
|
60
|
+
'cancelWebPush',
|
|
61
|
+
async (dataArg) => {
|
|
62
|
+
try {
|
|
63
|
+
const result = await this.opsServerRef.dcRouterRef.webPushManager.cancel(
|
|
64
|
+
dataArg.auth,
|
|
65
|
+
dataArg.target,
|
|
66
|
+
);
|
|
67
|
+
return {
|
|
68
|
+
success: true,
|
|
69
|
+
...result,
|
|
70
|
+
};
|
|
71
|
+
} catch (error: unknown) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
cancelledCount: 0,
|
|
75
|
+
alreadyTerminalCount: 0,
|
|
76
|
+
message: this.safeMessage(error),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
this.typedrouter.addTypedHandler(
|
|
84
|
+
new plugins.typedrequest.TypedHandler<
|
|
85
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_GetWebPushDeliveryStatus
|
|
86
|
+
>(
|
|
87
|
+
'getWebPushDeliveryStatus',
|
|
88
|
+
async (dataArg) => {
|
|
89
|
+
return {
|
|
90
|
+
delivery: await this.opsServerRef.dcRouterRef.webPushManager.getDeliveryStatus(
|
|
91
|
+
dataArg.auth,
|
|
92
|
+
dataArg.spoolItemId,
|
|
93
|
+
),
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private safeMessage(errorArg: unknown): string {
|
|
101
|
+
return errorArg instanceof plugins.typedrequest.TypedResponseError
|
|
102
|
+
? errorArg.message
|
|
103
|
+
: 'Web Push delivery operation failed';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import type { OpsServer } from '../classes.opsserver.js';
|
|
3
|
+
import type { WebPushBindingDoc } from '../../db/index.js';
|
|
4
|
+
import type { IWebPushControllerMutation } from '../../webpush/classes.webpush-manager.js';
|
|
5
|
+
import {
|
|
6
|
+
requireGatewayMachineAuth,
|
|
7
|
+
type IGatewayMachineAuthContext,
|
|
8
|
+
} from '../helpers/gateway-client-auth.js';
|
|
9
|
+
|
|
10
|
+
type IWebPushResourceOwner = plugins.servezoneInterfaces.data.IWebPushResourceOwner;
|
|
11
|
+
|
|
12
|
+
export class WebPushControlHandler {
|
|
13
|
+
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
14
|
+
|
|
15
|
+
public constructor(private readonly opsServerRef: OpsServer) {
|
|
16
|
+
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
17
|
+
this.registerHandlers();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private registerHandlers(): void {
|
|
21
|
+
this.typedrouter.addTypedHandler(
|
|
22
|
+
new plugins.typedrequest.TypedHandler<
|
|
23
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_ListWebPushBindings
|
|
24
|
+
>(
|
|
25
|
+
'listWebPushBindings',
|
|
26
|
+
async (dataArg) => {
|
|
27
|
+
const auth = await this.requireControlAuth(dataArg.auth, 'readWebPush');
|
|
28
|
+
const owner = this.scopeOwnerFilter(auth, dataArg.owner);
|
|
29
|
+
const manager = this.opsServerRef.dcRouterRef.webPushManager;
|
|
30
|
+
return {
|
|
31
|
+
bindings: await manager.listBindings(owner),
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
this.typedrouter.addTypedHandler(
|
|
38
|
+
new plugins.typedrequest.TypedHandler<
|
|
39
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_SyncWebPushBinding
|
|
40
|
+
>(
|
|
41
|
+
'syncWebPushBinding',
|
|
42
|
+
async (dataArg) => {
|
|
43
|
+
try {
|
|
44
|
+
const request = dataArg as typeof dataArg & {
|
|
45
|
+
controller: IWebPushControllerMutation;
|
|
46
|
+
};
|
|
47
|
+
const auth = await this.requireControlAuth(dataArg.auth, 'manageWebPush');
|
|
48
|
+
this.assertExactOwner(auth, dataArg.binding.owner);
|
|
49
|
+
const result = await this.opsServerRef.dcRouterRef.webPushManager.syncBinding(
|
|
50
|
+
dataArg.binding,
|
|
51
|
+
request.controller,
|
|
52
|
+
auth.userId,
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
success: true,
|
|
56
|
+
binding: result.binding,
|
|
57
|
+
credential: result.credential,
|
|
58
|
+
};
|
|
59
|
+
} catch (error: unknown) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
message: this.safeMessage(error),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
this.typedrouter.addTypedHandler(
|
|
70
|
+
new plugins.typedrequest.TypedHandler<
|
|
71
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_DeleteWebPushBinding
|
|
72
|
+
>(
|
|
73
|
+
'deleteWebPushBinding',
|
|
74
|
+
async (dataArg) => {
|
|
75
|
+
try {
|
|
76
|
+
const request = dataArg as typeof dataArg & {
|
|
77
|
+
id?: string;
|
|
78
|
+
owner: IWebPushResourceOwner;
|
|
79
|
+
controller: IWebPushControllerMutation;
|
|
80
|
+
};
|
|
81
|
+
const auth = await this.requireControlAuth(dataArg.auth, 'manageWebPush');
|
|
82
|
+
this.assertExactOwner(auth, request.owner);
|
|
83
|
+
return {
|
|
84
|
+
success: await this.opsServerRef.dcRouterRef.webPushManager.deleteBinding({
|
|
85
|
+
...(request.id !== undefined ? { id: request.id } : {}),
|
|
86
|
+
owner: request.owner,
|
|
87
|
+
controller: request.controller,
|
|
88
|
+
}, auth.userId),
|
|
89
|
+
};
|
|
90
|
+
} catch (error: unknown) {
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
message: this.safeMessage(error),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
this.typedrouter.addTypedHandler(
|
|
101
|
+
new plugins.typedrequest.TypedHandler<
|
|
102
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_RotateWebPushCredential
|
|
103
|
+
>(
|
|
104
|
+
'rotateWebPushCredential',
|
|
105
|
+
async (dataArg) => {
|
|
106
|
+
try {
|
|
107
|
+
const auth = await this.requireControlAuth(dataArg.auth, 'manageWebPush');
|
|
108
|
+
const manager = this.opsServerRef.dcRouterRef.webPushManager;
|
|
109
|
+
const binding = await manager.getBindingByCredentialId(dataArg.credentialId);
|
|
110
|
+
if (!binding) {
|
|
111
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push binding not found');
|
|
112
|
+
}
|
|
113
|
+
this.assertExactOwner(auth, this.bindingOwner(binding));
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
credential: await manager.rotateCredential(dataArg.credentialId),
|
|
117
|
+
};
|
|
118
|
+
} catch (error: unknown) {
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
message: this.safeMessage(error),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
this.typedrouter.addTypedHandler(
|
|
129
|
+
new plugins.typedrequest.TypedHandler<
|
|
130
|
+
plugins.servezoneInterfaces.requests.webpush.IReq_RotateWebPushVapidKey
|
|
131
|
+
>(
|
|
132
|
+
'rotateWebPushVapidKey',
|
|
133
|
+
async (dataArg) => {
|
|
134
|
+
try {
|
|
135
|
+
const auth = await this.requireControlAuth(dataArg.auth, 'manageWebPush');
|
|
136
|
+
const binding = await this.requireAuthorizedBinding(auth, dataArg.bindingId);
|
|
137
|
+
return {
|
|
138
|
+
success: true,
|
|
139
|
+
binding: await this.opsServerRef.dcRouterRef.webPushManager.rotateVapidKey(
|
|
140
|
+
binding.id,
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
} catch (error: unknown) {
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
message: this.safeMessage(error),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async requireControlAuth(
|
|
155
|
+
authArg: plugins.servezoneInterfaces.requests.webpush.IWebPushControlRequestAuth,
|
|
156
|
+
capabilityArg: 'readWebPush' | 'manageWebPush',
|
|
157
|
+
): Promise<IGatewayMachineAuthContext> {
|
|
158
|
+
const auth = await requireGatewayMachineAuth(
|
|
159
|
+
this.opsServerRef,
|
|
160
|
+
authArg,
|
|
161
|
+
capabilityArg === 'manageWebPush'
|
|
162
|
+
? 'gateway-clients:write'
|
|
163
|
+
: 'gateway-clients:read',
|
|
164
|
+
);
|
|
165
|
+
if (auth.isAdmin) return auth;
|
|
166
|
+
if (
|
|
167
|
+
auth.policy?.role !== 'gatewayClient'
|
|
168
|
+
|| auth.policy.capabilities?.[capabilityArg] !== true
|
|
169
|
+
) {
|
|
170
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
171
|
+
`token capability missing: ${capabilityArg}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return auth;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private scopeOwnerFilter(
|
|
178
|
+
authArg: IGatewayMachineAuthContext,
|
|
179
|
+
requestedArg: Partial<IWebPushResourceOwner> | undefined,
|
|
180
|
+
): Partial<IWebPushResourceOwner> {
|
|
181
|
+
if (authArg.isAdmin) return requestedArg || {};
|
|
182
|
+
const gatewayClient = authArg.gatewayClient;
|
|
183
|
+
if (!gatewayClient) {
|
|
184
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
185
|
+
'gateway-client credential required',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
requestedArg?.gatewayClientType !== undefined
|
|
190
|
+
&& requestedArg.gatewayClientType !== gatewayClient.type
|
|
191
|
+
) {
|
|
192
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
193
|
+
'gateway-client credential cannot access another Web Push owner',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (
|
|
197
|
+
requestedArg?.gatewayClientId !== undefined
|
|
198
|
+
&& requestedArg.gatewayClientId !== gatewayClient.id
|
|
199
|
+
) {
|
|
200
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
201
|
+
'gateway-client credential cannot access another Web Push owner',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
gatewayClientType: gatewayClient.type,
|
|
206
|
+
gatewayClientId: gatewayClient.id,
|
|
207
|
+
...(requestedArg?.appInstanceId !== undefined
|
|
208
|
+
? { appInstanceId: requestedArg.appInstanceId }
|
|
209
|
+
: {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private assertExactOwner(
|
|
214
|
+
authArg: IGatewayMachineAuthContext,
|
|
215
|
+
ownerArg: IWebPushResourceOwner,
|
|
216
|
+
): void {
|
|
217
|
+
if (authArg.isAdmin) return;
|
|
218
|
+
const gatewayClient = authArg.gatewayClient;
|
|
219
|
+
if (
|
|
220
|
+
!gatewayClient
|
|
221
|
+
|| ownerArg.gatewayClientType !== gatewayClient.type
|
|
222
|
+
|| ownerArg.gatewayClientId !== gatewayClient.id
|
|
223
|
+
) {
|
|
224
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
225
|
+
'gateway-client credential cannot access another Web Push owner',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async requireAuthorizedBinding(
|
|
231
|
+
authArg: IGatewayMachineAuthContext,
|
|
232
|
+
bindingIdArg: string,
|
|
233
|
+
): Promise<WebPushBindingDoc> {
|
|
234
|
+
const binding = await this.opsServerRef.dcRouterRef.webPushManager.getBindingById(
|
|
235
|
+
bindingIdArg,
|
|
236
|
+
);
|
|
237
|
+
if (!binding) {
|
|
238
|
+
throw new plugins.typedrequest.TypedResponseError('Web Push binding not found');
|
|
239
|
+
}
|
|
240
|
+
this.assertExactOwner(authArg, this.bindingOwner(binding));
|
|
241
|
+
return binding;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private bindingOwner(bindingArg: WebPushBindingDoc): IWebPushResourceOwner {
|
|
245
|
+
return {
|
|
246
|
+
gatewayClientType: bindingArg.ownerGatewayClientType,
|
|
247
|
+
gatewayClientId: bindingArg.ownerGatewayClientId,
|
|
248
|
+
appInstanceId: bindingArg.ownerAppInstanceId,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private safeMessage(errorArg: unknown): string {
|
|
253
|
+
return errorArg instanceof plugins.typedrequest.TypedResponseError
|
|
254
|
+
? errorArg.message
|
|
255
|
+
: 'Web Push control operation failed';
|
|
256
|
+
}
|
|
257
|
+
}
|