@serve.zone/dcrouter 17.7.1 → 17.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +712 -709
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +2 -0
  5. package/dist_ts/classes.dcrouter.js +23 -1
  6. package/dist_ts/config/classes.db-seeder.js +9 -1
  7. package/dist_ts/config/classes.gateway-client-manager.js +3 -1
  8. package/dist_ts/config/helpers.http-redirects.js +28 -1
  9. package/dist_ts/config/helpers.special-forwards.js +12 -9
  10. package/dist_ts/db/documents/classes.cached.email.d.ts +7 -0
  11. package/dist_ts/db/documents/classes.cached.email.js +52 -2
  12. package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
  13. package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
  14. package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
  15. package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
  16. package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
  17. package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
  18. package/dist_ts/db/documents/index.d.ts +3 -0
  19. package/dist_ts/db/documents/index.js +5 -1
  20. package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
  21. package/dist_ts/email/classes.accepted-email-spool.js +28 -3
  22. package/dist_ts/email/classes.email-route-builder.js +5 -1
  23. package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
  24. package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
  25. package/dist_ts/index.d.ts +1 -0
  26. package/dist_ts/index.js +3 -1
  27. package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
  28. package/dist_ts/opsserver/classes.opsserver.js +5 -1
  29. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +33 -6
  30. package/dist_ts/opsserver/handlers/index.d.ts +2 -0
  31. package/dist_ts/opsserver/handlers/index.js +3 -1
  32. package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
  33. package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
  34. package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
  35. package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
  36. package/dist_ts/plugins.d.ts +5 -2
  37. package/dist_ts/plugins.js +6 -3
  38. package/dist_ts/remoteingress/edge-derivation-routes.js +5 -1
  39. package/dist_ts/security/classes.route-policy-augmenter.d.ts +10 -6
  40. package/dist_ts/security/classes.route-policy-augmenter.js +65 -51
  41. package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
  42. package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
  43. package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
  44. package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
  45. package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
  46. package/dist_ts/webpush/classes.webpush-transport.js +161 -0
  47. package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
  48. package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
  49. package/dist_ts/webpush/index.d.ts +4 -0
  50. package/dist_ts/webpush/index.js +5 -0
  51. package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
  52. package/dist_ts/webpush/webpush-indexes.js +123 -0
  53. package/dist_ts_interfaces/data/remoteingress.d.ts +14 -2
  54. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  55. package/dist_ts_interfaces/data/route-management.js +1 -1
  56. package/dist_ts_migrations/index.js +285 -11
  57. package/dist_ts_web/00_commitinfo_data.js +1 -1
  58. package/dist_ts_web/elements/network/ops-view-routes.js +41 -1
  59. package/dist_ts_web/elements/network/special-forward-dialog.js +2 -2
  60. package/package.json +14 -10
  61. package/readme.md +27 -0
  62. package/third-party-notices.md +37 -0
  63. package/ts/00_commitinfo_data.ts +1 -1
  64. package/ts/classes.dcrouter.ts +29 -0
  65. package/ts/config/classes.db-seeder.ts +8 -0
  66. package/ts/config/classes.gateway-client-manager.ts +2 -0
  67. package/ts/config/helpers.http-redirects.ts +31 -1
  68. package/ts/config/helpers.special-forwards.ts +11 -8
  69. package/ts/db/documents/classes.cached.email.ts +47 -0
  70. package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
  71. package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
  72. package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
  73. package/ts/db/documents/index.ts +5 -0
  74. package/ts/email/classes.accepted-email-spool.ts +41 -2
  75. package/ts/email/classes.email-route-builder.ts +4 -0
  76. package/ts/email/classes.workapp-mail-manager.ts +97 -10
  77. package/ts/index.ts +3 -0
  78. package/ts/opsserver/classes.opsserver.ts +4 -0
  79. package/ts/opsserver/handlers/gatewayclient.handler.ts +34 -4
  80. package/ts/opsserver/handlers/index.ts +2 -0
  81. package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
  82. package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
  83. package/ts/plugins.ts +6 -0
  84. package/ts/remoteingress/edge-derivation-routes.ts +4 -0
  85. package/ts/security/classes.route-policy-augmenter.ts +70 -57
  86. package/ts/webpush/classes.webpush-crypto.ts +306 -0
  87. package/ts/webpush/classes.webpush-manager.ts +3173 -0
  88. package/ts/webpush/classes.webpush-transport.ts +241 -0
  89. package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
  90. package/ts/webpush/index.ts +4 -0
  91. package/ts/webpush/webpush-indexes.ts +141 -0
  92. package/ts_web/00_commitinfo_data.ts +1 -1
  93. package/ts_web/elements/network/ops-view-routes.ts +38 -0
  94. package/ts_web/elements/network/special-forward-dialog.ts +1 -1
@@ -98,6 +98,10 @@ export class EmailRouteBuilder {
98
98
  },
99
99
  action: action
100
100
  };
101
+ routeConfig.ingress = {
102
+ directHub: !this.dcRouterRef.isRemoteIngressHubEnabled(),
103
+ smartVpn: !this.dcRouterRef.isRemoteIngressHubEnabled(),
104
+ };
101
105
 
102
106
  if (this.dcRouterRef.isRemoteIngressHubEnabled()) {
103
107
  // Only mail-tagged edges bind SMTP ports — other edges may sit on hosts
@@ -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) => {
@@ -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
 
@@ -1362,6 +1369,19 @@ export class GatewayClientHandler {
1362
1369
  gatewayDnsMode: dnsMode,
1363
1370
  };
1364
1371
  const normalizedRoute = this.normalizeGatewayClientRoute(validatedRoute, resolvedOwnership, externalKey);
1372
+ if (!normalizedRoute.ingress) {
1373
+ const existingIngress = existingRoute?.route.ingress;
1374
+ normalizedRoute.ingress = existingIngress
1375
+ ? structuredClone(existingIngress)
1376
+ : {
1377
+ directHub: true,
1378
+ smartVpn: true,
1379
+ };
1380
+ console.warn(
1381
+ `Gateway client route '${externalKey}' omitted route.ingress; `
1382
+ + `${existingIngress ? 'preserved stored policy' : 'applied legacy directHub+smartVpn compatibility policy'}`,
1383
+ );
1384
+ }
1365
1385
 
1366
1386
  if (existingRoute) {
1367
1387
  const snapshot = structuredClone(existingRoute);
@@ -1589,7 +1609,7 @@ export class GatewayClientHandler {
1589
1609
  };
1590
1610
 
1591
1611
  const route = requireObject(routeArg, 'route');
1592
- requireOnlyKeys(route, ['name', 'match', 'action', 'priority', 'managedRouteKind', 'remoteIngress'], 'route');
1612
+ requireOnlyKeys(route, ['name', 'match', 'action', 'priority', 'managedRouteKind', 'ingress', 'remoteIngress'], 'route');
1593
1613
  const name = requireString(route.name, 'route.name');
1594
1614
 
1595
1615
  const match = requireObject(route.match, 'route.match');
@@ -1663,6 +1683,15 @@ export class GatewayClientHandler {
1663
1683
  if (route.managedRouteKind !== 'letsencrypt-http01-forward') fail('route.managedRouteKind is unsupported');
1664
1684
  managedRouteKind = route.managedRouteKind as 'letsencrypt-http01-forward';
1665
1685
  }
1686
+ let ingress: { directHub: boolean; smartVpn: boolean } | undefined;
1687
+ if (route.ingress !== undefined) {
1688
+ const ingressValue = requireObject(route.ingress, 'route.ingress');
1689
+ requireOnlyKeys(ingressValue, ['directHub', 'smartVpn'], 'route.ingress');
1690
+ ingress = {
1691
+ directHub: requireBoolean(ingressValue.directHub, 'route.ingress.directHub'),
1692
+ smartVpn: requireBoolean(ingressValue.smartVpn, 'route.ingress.smartVpn'),
1693
+ };
1694
+ }
1666
1695
  let remoteIngress: { enabled: boolean; edgeFilter?: string[] } | undefined;
1667
1696
  if (route.remoteIngress !== undefined) {
1668
1697
  const remoteValue = requireObject(route.remoteIngress, 'route.remoteIngress');
@@ -1687,6 +1716,7 @@ export class GatewayClientHandler {
1687
1716
  action: { type: 'forward', targets, ...(tls ? { tls } : {}), ...(websocket ? { websocket } : {}) },
1688
1717
  ...(priority !== undefined ? { priority } : {}),
1689
1718
  ...(managedRouteKind ? { managedRouteKind } : {}),
1719
+ ...(ingress ? { ingress } : {}),
1690
1720
  ...(remoteIngress ? { remoteIngress } : {}),
1691
1721
  };
1692
1722
  }
@@ -1747,7 +1777,7 @@ export class GatewayClientHandler {
1747
1777
  ): interfaces.data.IRouteSourceBinding[] | undefined {
1748
1778
  const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
1749
1779
  const profiles = resolver?.listProfiles() || [];
1750
- const wantedRef = (requestedProfileRefArg || 'public').trim().toLowerCase();
1780
+ const wantedRef = (requestedProfileRefArg?.trim() || 'standard').toLowerCase();
1751
1781
  const profile = profiles.find((profileArg: interfaces.data.ISourceProfile) => {
1752
1782
  return profileArg.id.trim().toLowerCase() === wantedRef;
1753
1783
  }) || 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
+ }
package/ts/plugins.ts CHANGED
@@ -3,6 +3,7 @@ import * as dns from 'node:dns';
3
3
  import * as fs from 'node:fs';
4
4
  import * as crypto from 'node:crypto';
5
5
  import * as http from 'node:http';
6
+ import * as https from 'node:https';
6
7
  import * as net from 'node:net';
7
8
  import * as buffer from 'node:buffer';
8
9
  import * as os from 'node:os';
@@ -16,6 +17,7 @@ export {
16
17
  fs,
17
18
  crypto,
18
19
  http,
20
+ https,
19
21
  net,
20
22
  buffer,
21
23
  os,
@@ -99,8 +101,12 @@ export {
99
101
  }
100
102
 
101
103
  // third party
104
+ import ipaddr from 'ipaddr.js';
102
105
  import * as uuid from 'uuid';
106
+ import webpush from 'web-push';
103
107
 
104
108
  export {
109
+ ipaddr,
105
110
  uuid,
111
+ webpush,
106
112
  }
@@ -21,6 +21,10 @@ export function buildEdgeOnlyDerivationRoutes(options: { dnsNsDomains?: string[]
21
21
  type: 'forward',
22
22
  targets: [{ host: '127.0.0.1', port: 53 }],
23
23
  },
24
+ ingress: {
25
+ directHub: false,
26
+ smartVpn: false,
27
+ },
24
28
  remoteIngress: {
25
29
  enabled: true,
26
30
  edgeFilter: [REMOTE_INGRESS_DNS_TAG],