@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
@@ -145,6 +145,10 @@ export interface IAcceptRawEmailOptions {
145
145
  processAfterAccept?: boolean;
146
146
  /** Explicit direction override for programmatic submitters. Defaults to the session-authenticated rule. */
147
147
  direction?: TCachedEmailDirection;
148
+ /** Exact authenticated outbound identity and replay metadata. */
149
+ submissionCredentialId?: string;
150
+ submissionIdempotencyKey?: string;
151
+ submissionDigest?: string;
148
152
  }
149
153
 
150
154
  /**
@@ -221,7 +225,7 @@ export class AcceptedEmailSpool {
221
225
  await cachedEmail.save();
222
226
  await this.notifyEmailQueuePersisted(cachedEmail, 'email-accepted');
223
227
  } catch (error) {
224
- await this.deleteRawMessage(cachedEmail).catch(() => undefined);
228
+ await this.cleanupRawMessageAfterSaveFailure(cachedEmail, error);
225
229
  throw error;
226
230
  }
227
231
  if (context.abortSignal?.aborted) {
@@ -262,6 +266,9 @@ export class AcceptedEmailSpool {
262
266
  cachedEmail.cc = [];
263
267
  cachedEmail.bcc = [];
264
268
  cachedEmail.subject = optionsArg.subject || this.extractHeader(rawMessage, 'subject') || '';
269
+ cachedEmail.submissionCredentialId = optionsArg.submissionCredentialId;
270
+ cachedEmail.submissionIdempotencyKey = optionsArg.submissionIdempotencyKey;
271
+ cachedEmail.submissionDigest = optionsArg.submissionDigest;
265
272
  const persistedRawMessage = this.setDcRouterCacheIdHeader(rawMessage, cachedEmail.id);
266
273
  await this.persistRawMessage(cachedEmail, persistedRawMessage);
267
274
  cachedEmail.status = 'pending';
@@ -287,7 +294,7 @@ export class AcceptedEmailSpool {
287
294
  await cachedEmail.save();
288
295
  await this.notifyEmailQueuePersisted(cachedEmail, 'raw-email-accepted');
289
296
  } catch (error) {
290
- await this.deleteRawMessage(cachedEmail).catch(() => undefined);
297
+ await this.cleanupRawMessageAfterSaveFailure(cachedEmail, error);
291
298
  throw error;
292
299
  }
293
300
 
@@ -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
- const acceptance = await this.dcRouterRef.acceptedEmailSpool.acceptRawMessage({
401
- rawMessage,
402
- envelope: {
403
- mailFrom: identity.address,
404
- rcptTo: recipients,
405
- },
406
- session,
407
- messageId: this.extractHeader(rawMessage, 'message-id'),
408
- subject: messageArg.subject,
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
- const nextAttemptMs = doc.nextAttempt ? new Date(doc.nextAttempt).getTime() : NaN;
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
- ...((base.status === 'pending' || base.status === 'deferred') && Number.isFinite(nextAttemptMs) && nextAttemptMs > 0
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
- : 'PUBLIC source profile not found',
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 || 'public').trim().toLowerCase();
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
+ }