@serve.zone/dcrouter 34.6.0 → 34.7.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "34.6.0",
4
+ "version": "34.7.1",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -15,6 +15,8 @@
15
15
  "author": "Task Venture Capital GmbH",
16
16
  "license": "MIT",
17
17
  "devDependencies": {
18
+ "@api.global/typedrequest-coremail-client": "npm:@api.global/typedrequest@9.1.0",
19
+ "@api.global/typedsocket-coremail-client": "npm:@api.global/typedsocket@9.0.2",
18
20
  "@git.zone/tsdeno": "^1.15.0",
19
21
  "@git.zone/tsdocker": "^3.14.0",
20
22
  "@git.zone/tsrepo": "^2.1.0",
@@ -29,8 +31,10 @@
29
31
  },
30
32
  "dependencies": {
31
33
  "@api.global/typedrequest": "8.0.4",
34
+ "@api.global/typedrequest-coremail": "npm:@api.global/typedrequest@9.2.2",
32
35
  "@api.global/typedrequest-interfaces": "7.1.0",
33
36
  "@api.global/typedserver": "13.0.2",
37
+ "@api.global/typedserver-coremail": "npm:@api.global/typedserver@16.0.2",
34
38
  "@api.global/typedsocket": "8.5.0",
35
39
  "@apiclient.xyz/cloudflare": "^8.0.2",
36
40
  "@aws-sdk/client-s3": "^3.1111.0",
package/readme.md CHANGED
@@ -161,7 +161,7 @@ Bootstrap behavior:
161
161
  | `baseDir` | Root directory for dcrouter runtime data. Defaults to `~/.serve.zone/dcrouter`. |
162
162
  | `coreTrafficConfig` | Main CoreTraffic route configuration for HTTP/HTTPS/TCP/SNI traffic. `smartProxyConfig` remains accepted as a legacy alias. |
163
163
  | `emailConfig` | UnifiedEmailServer configuration: hostname, ports, domains, and mail routes. |
164
- | `emailOutboundMode` | Outbound SMTP mode. Defaults to `remoteIngress`; explicit `direct` mode lets unmanaged/static senders dial from the hub. |
164
+ | `emailOutboundMode` | Outbound SMTP mode; explicit `direct` mode lets unmanaged/static senders dial from the hub. With the database enabled it seeds the stored email settings on first start only; after that the stored settings are authoritative, they default to `remoteIngress`, and the mode changes through the email settings API or Ops UI. Without the database it is read directly on every delivery and has no default: while it is unset, outbound mail defers. |
165
165
  | `emailPortConfig` | External-to-internal email port mapping and received-email storage path. |
166
166
  | `tls` | Legacy/static TLS and ACME contact settings used to seed certificate config. |
167
167
  | `dnsNsDomains` | Nameserver hostnames used for generated NS records and DoH routes. A zone becomes authoritative by having its public NS records observed naming one of these. |
@@ -1172,11 +1172,27 @@ order, so the producer canonicalises the string before sending it.
1172
1172
 
1173
1173
  | Setting | Owner | Canonical form |
1174
1174
  | --- | --- | --- |
1175
- | `gateway.endpointUrl` (in CoreMail's desired state) | producer | `wss://<dcrouter-ops-host>/` — **with** the trailing slash |
1175
+ | `gateway.endpointUrl` (in CoreMail's desired state) | producer | `wss://<gateway-host>/` — **with** the trailing slash |
1176
1176
  | `transferOrigin` (in the peer above) | producer | `https://<coremail-host>` — origin only, **no** trailing slash |
1177
1177
 
1178
- `endpointUrl` points at dcrouter's ops server port (`opsServerPort`, default
1179
- `3000`) — the same TypedSocket surface platformclient's `MAIL_TYPED_URL` reaches.
1178
+ Which host `endpointUrl` names depends on CoreMail's TypedSocket major, because
1179
+ the TypedSocket handshake refuses a peer of another wire:
1180
+
1181
+ - CoreMail up to 32 (TypedSocket 8) dials dcrouter's ops server port
1182
+ (`opsServerPort`, default `3000`) — the same TypedSocket surface
1183
+ platformclient's `MAIL_TYPED_URL` reaches.
1184
+ - CoreMail 33 and later (TypedSocket 9; wire 9 is shared by TypedSocket 9 and 10)
1185
+ dials the CoreMail gateway listener, `IDcRouterOptions.coreMailGatewayServer`.
1186
+ It is disabled when the option is absent. The option has `port` and a nonempty
1187
+ exact `hostnames` list; it always binds `127.0.0.1` and serves its socket at `/`,
1188
+ so the operator routes one hub hostname to that port and lists the same hostname
1189
+ in `hostnames` (other hosts, other paths, HTTP and browser Origins are answered
1190
+ `404`). In OCI mode the option goes into the `DCROUTER_CONFIG_PATH` JSON. The
1191
+ listener serves the three gateway calls and nothing else: no Ops, admin or
1192
+ workload mail methods. It authenticates with the CoreMail credential behind the
1193
+ same per-credential-id attempt limit as the ops surface and adds no source-address
1194
+ check. The ops server keeps serving CoreMail on TypedSocket 8 alongside it.
1195
+
1180
1196
  `transferOrigin` is CoreMail's own HTTPS origin, from which dcrouter fetches and
1181
1197
  to which it uploads message bytes; dcrouter returns it verbatim during
1182
1198
  authentication and CoreMail refuses the session unless it matches its own desired
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '34.6.0',
6
+ version: '34.7.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -25,6 +25,7 @@ import { createApplicationMigrationRunner } from '../ts_migrations/index.js';
25
25
 
26
26
  import { OpsServer } from './opsserver/index.js';
27
27
  import type { IWorkloadMailServerOptions } from './opsserver/classes.workload-mail-server.js';
28
+ import type { ICoreMailGatewayServerOptions } from './opsserver/classes.coremail-gateway-server.js';
28
29
  import { MetricsManager, OpsEventManager } from './monitoring/index.js';
29
30
  import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
30
31
  import { RemoteIngressHubLifecycle, RemoteIngressManager, TunnelManager, buildEdgeOnlyDerivationRoutes } from './remoteingress/index.js';
@@ -115,7 +116,13 @@ export interface IDcRouterOptions {
115
116
  /** SmartBucket configuration for durable SmartMTA queue and attachment blobs. */
116
117
  emailBlobStorage?: ISmartMtaBlobStorageConfig;
117
118
 
118
- /** Outbound SMTP connection mode. Defaults to direct unless DB settings override it. */
119
+ /**
120
+ * Outbound SMTP connection mode. With the database enabled it seeds the
121
+ * stored email settings on first start only; after that the stored settings
122
+ * are authoritative, and they default to `remoteIngress`. Without the
123
+ * database it is read directly on every delivery and has no default: while
124
+ * it is unset, outbound mail defers.
125
+ */
119
126
  emailOutboundMode?: TEmailOutboundMode;
120
127
 
121
128
  /**
@@ -230,6 +237,13 @@ export interface IDcRouterOptions {
230
237
  /** Disabled unless explicitly configured; loopback-only, separately routed two-operation mail API. */
231
238
  workloadMailServer?: IWorkloadMailServerOptions;
232
239
 
240
+ /**
241
+ * Disabled unless explicitly configured; loopback-only listener that serves the CoreMail gateway,
242
+ * and nothing else, to CoreMail on TypedSocket 9 or 10, behind one hub hostname route (Linus
243
+ * ruling 70, L2). The Ops server keeps serving the gateway on TypedSocket 8.
244
+ */
245
+ coreMailGatewayServer?: ICoreMailGatewayServerOptions;
246
+
233
247
  /** Optional OpsServer account authentication settings. */
234
248
  adminAuth?: {
235
249
  /** Optional idp.global password-authentication URL override. Defaults to the SDK's hosted https://app.idp.global endpoint. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
@@ -479,9 +493,10 @@ export class DcRouter {
479
493
  store: new CoreMailGatewayStore(),
480
494
  runtime: createCoreMailGatewayRuntime(this),
481
495
  });
482
- // CoreMail connects as a TypedSocket client to the ops surface, which
483
- // serves this router through the OpsServer's own chain. Registering here
484
- // keeps the manager independent of OpsServer construction.
496
+ // CoreMail on TypedSocket 8 connects as a TypedSocket client to the ops
497
+ // surface, which serves this router through the OpsServer's own chain.
498
+ // Registering here keeps the manager independent of OpsServer construction.
499
+ // CoreMail on wire 9 connects to the `coreMailGatewayServer` listener instead.
485
500
  this.typedrouter.addTypedRouter(this.coreMailGatewayManager.typedrouter);
486
501
  this.smtpAccountManager = new SmtpAccountManager(this);
487
502
  this.acceptedEmailSpool = new AcceptedEmailSpool(this);
@@ -67,6 +67,19 @@ interface ICoreMailPeerLike {
67
67
  context: { state: Record<string, unknown> };
68
68
  }
69
69
 
70
+ /**
71
+ * A CoreMail connection on one of the sockets the gateway is served on: the Ops
72
+ * server's TypedSocket 8 or the CoreMail gateway listener's wire 9. A request to
73
+ * CoreMail goes out on the socket the connection belongs to.
74
+ */
75
+ export interface ICoreMailGatewayConnection {
76
+ peer: ICoreMailPeerLike;
77
+ fire<T extends plugins.typedrequestInterfaces.ITypedRequest>(
78
+ methodArg: T['method'],
79
+ requestArg: T['request'],
80
+ ): Promise<T['response']>;
81
+ }
82
+
70
83
  interface ICoreMailQueueItemLike {
71
84
  id: string;
72
85
  status: 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
@@ -87,11 +100,8 @@ export interface ICoreMailGatewayRuntime {
87
100
  idempotencyKeyArg: string,
88
101
  ): Promise<string>;
89
102
  getQueueItem(queueItemIdArg: string): ICoreMailQueueItemLike | undefined;
90
- findConnection(tagArg: string): Promise<{ peer: ICoreMailPeerLike } | undefined>;
91
- createRequest<T extends plugins.typedrequestInterfaces.ITypedRequest>(
92
- methodArg: T['method'],
93
- connectionArg: unknown,
94
- ): { fire(requestArg: T['request']): Promise<T['response']> };
103
+ /** The connection with this routing tag on any socket the gateway is served on. */
104
+ findConnection(tagArg: string): Promise<ICoreMailGatewayConnection | undefined>;
95
105
  }
96
106
 
97
107
  export interface ICoreMailGatewayManagerOptions {
@@ -121,14 +131,55 @@ export class CoreMailInboundRefusedError extends Error {
121
131
  }
122
132
  }
123
133
 
134
+ /**
135
+ * The contract error a gateway request is refused with. It is definitive
136
+ * unless it names when to retry: CoreMail takes a non-retryable
137
+ * `AUTHENTICATION_FAILED` as the verdict on its credential and stops
138
+ * connecting with it.
139
+ */
124
140
  const contractError = (
125
141
  codeArg: TErrorCode,
126
- retryableArg = false,
142
+ retryAfterMsArg?: number,
127
143
  ): plugins.typedrequest.TypedResponseError =>
128
- new plugins.typedrequest.TypedResponseError('CoreMail gateway request rejected.', {
129
- code: codeArg,
130
- retryable: retryableArg,
131
- } satisfies plugins.servezoneInterfaces.data.ICoreMailErrorData);
144
+ new plugins.typedrequest.TypedResponseError(
145
+ 'CoreMail gateway request rejected.',
146
+ (retryAfterMsArg === undefined
147
+ ? { code: codeArg, retryable: false }
148
+ : { code: codeArg, retryable: true, retryAfterMs: retryAfterMsArg }
149
+ ) satisfies plugins.servezoneInterfaces.data.ICoreMailErrorData,
150
+ );
151
+
152
+ /**
153
+ * Whether a replayed envelope is the one the stored handoff was prepared with.
154
+ * Compares values, never encodings: the stored envelope is whatever the
155
+ * document store returns, and its key order is not part of the data. The
156
+ * recipient order is, as it is in the submission digest.
157
+ */
158
+ const isSameCoreMailEnvelope = (
159
+ storedArg: plugins.servezoneInterfaces.data.ICoreMailEnvelope,
160
+ replayedArg: plugins.servezoneInterfaces.data.ICoreMailEnvelope,
161
+ ): boolean =>
162
+ storedArg.mailFrom === replayedArg.mailFrom
163
+ && storedArg.rcptTo.length === replayedArg.rcptTo.length
164
+ && storedArg.rcptTo.every((recipientArg, indexArg) => recipientArg === replayedArg.rcptTo[indexArg]);
165
+
166
+ /**
167
+ * Answers a wire-9 request with the handler's own contract refusal: TypedRequest 9
168
+ * answers an error of another class, TypedRequest 8's `TypedResponseError`
169
+ * included, as an internal error without its `errorData`.
170
+ */
171
+ const onCoreMailGatewayWire = <TRequest, TResponse>(
172
+ handlerArg: (requestArg: TRequest, toolsArg: unknown) => Promise<TResponse>,
173
+ ) => async (requestArg: TRequest, toolsArg?: unknown): Promise<TResponse> => {
174
+ try {
175
+ return await handlerArg(requestArg, toolsArg);
176
+ } catch (error: unknown) {
177
+ if (error instanceof plugins.typedrequest.TypedResponseError) {
178
+ throw new plugins.typedrequestCoreMail.TypedResponseError(error.errorText, error.errorData);
179
+ }
180
+ throw error;
181
+ }
182
+ };
132
183
 
133
184
  const toTimestamp = (valueArg: Date | number | undefined): number | undefined => {
134
185
  if (valueArg === undefined) return undefined;
@@ -146,6 +197,7 @@ const toTimestamp = (valueArg: Date | number | undefined): number | undefined =>
146
197
  * `…CompleteInboundHandoff` and `…CompleteOutboundHandoff` back on the peer.
147
198
  */
148
199
  export class CoreMailGatewayManager {
200
+ /** The gateway's handlers on TypedSocket 8, which the Ops server serves. */
149
201
  public typedrouter = new plugins.typedrequest.TypedRouter();
150
202
 
151
203
  private readonly store: ICoreMailGatewayStore;
@@ -184,7 +236,7 @@ export class CoreMailGatewayManager {
184
236
  this.verifyCredential = optionsArg.verifyCredential
185
237
  ?? (async (hashArg, secretArg) => await plugins.argon2.verify(hashArg, secretArg));
186
238
  this.enqueueRetryDelaysMs = optionsArg.enqueueRetryDelaysMs ?? [1_000, 5_000, 15_000];
187
- this.registerHandlers();
239
+ this.registerGatewayHandlers(this.typedrouter);
188
240
  }
189
241
 
190
242
  // -------------------------------------------------------- peer provisioning
@@ -274,31 +326,52 @@ export class CoreMailGatewayManager {
274
326
 
275
327
  // ---------------------------------------------------------------- handlers
276
328
 
277
- private registerHandlers(): void {
278
- this.typedrouter.addTypedHandler(
279
- new plugins.typedrequest.TypedHandler<TReq_Authenticate>(
329
+ /**
330
+ * Registers the gateway's three handlers on the router it is handed (Linus
331
+ * rulings 39, 3f, and 70, L2): this manager's `typedrouter` (TypedSocket 8,
332
+ * served by the Ops server) and the CoreMail gateway listener's router (wire 9,
333
+ * `CoreMailGatewayServer`). Both run the same handler bodies, and the session
334
+ * an authentication sets lives on the peer of the socket it arrived on.
335
+ */
336
+ public registerGatewayHandlers(
337
+ routerArg: plugins.typedrequest.TypedRouter | plugins.typedrequestCoreMail.TypedRouter,
338
+ ): void {
339
+ const authenticate = async (dataArg: TReq_Authenticate['request'], toolsArg: unknown) =>
340
+ await this.authenticate(this.requirePeer(toolsArg), dataArg);
341
+ const prepareOutboundHandoff = async (
342
+ dataArg: TReq_PrepareOutboundHandoff['request'],
343
+ toolsArg: unknown,
344
+ ) => await this.prepareOutboundHandoff(this.requireSession(this.requirePeer(toolsArg)), dataArg);
345
+ const getOutboundStatus = async (dataArg: TReq_GetOutboundStatus['request'], toolsArg: unknown) =>
346
+ await this.getOutboundStatus(this.requireSession(this.requirePeer(toolsArg)), dataArg);
347
+
348
+ if (routerArg instanceof plugins.typedrequestCoreMail.TypedRouter) {
349
+ routerArg.addTypedHandler(new plugins.typedrequestCoreMail.TypedHandler<TReq_Authenticate>(
280
350
  'coreMailGatewayAuthenticate',
281
- async (dataArg, toolsArg) => await this.authenticate(this.requirePeer(toolsArg), dataArg),
282
- ),
283
- );
284
- this.typedrouter.addTypedHandler(
285
- new plugins.typedrequest.TypedHandler<TReq_PrepareOutboundHandoff>(
351
+ onCoreMailGatewayWire(authenticate),
352
+ ));
353
+ routerArg.addTypedHandler(new plugins.typedrequestCoreMail.TypedHandler<TReq_PrepareOutboundHandoff>(
286
354
  'coreMailGatewayPrepareOutboundHandoff',
287
- async (dataArg, toolsArg) => await this.prepareOutboundHandoff(
288
- this.requireSession(this.requirePeer(toolsArg)),
289
- dataArg,
290
- ),
291
- ),
292
- );
293
- this.typedrouter.addTypedHandler(
294
- new plugins.typedrequest.TypedHandler<TReq_GetOutboundStatus>(
355
+ onCoreMailGatewayWire(prepareOutboundHandoff),
356
+ ));
357
+ routerArg.addTypedHandler(new plugins.typedrequestCoreMail.TypedHandler<TReq_GetOutboundStatus>(
295
358
  'coreMailGatewayGetOutboundStatus',
296
- async (dataArg, toolsArg) => await this.getOutboundStatus(
297
- this.requireSession(this.requirePeer(toolsArg)),
298
- dataArg,
299
- ),
300
- ),
301
- );
359
+ onCoreMailGatewayWire(getOutboundStatus),
360
+ ));
361
+ return;
362
+ }
363
+ routerArg.addTypedHandler(new plugins.typedrequest.TypedHandler<TReq_Authenticate>(
364
+ 'coreMailGatewayAuthenticate',
365
+ authenticate,
366
+ ));
367
+ routerArg.addTypedHandler(new plugins.typedrequest.TypedHandler<TReq_PrepareOutboundHandoff>(
368
+ 'coreMailGatewayPrepareOutboundHandoff',
369
+ prepareOutboundHandoff,
370
+ ));
371
+ routerArg.addTypedHandler(new plugins.typedrequest.TypedHandler<TReq_GetOutboundStatus>(
372
+ 'coreMailGatewayGetOutboundStatus',
373
+ getOutboundStatus,
374
+ ));
302
375
  }
303
376
 
304
377
  private requirePeer(toolsArg: unknown): ICoreMailPeerLike {
@@ -320,8 +393,9 @@ export class CoreMailGatewayManager {
320
393
 
321
394
  /**
322
395
  * Every gateway method other than the handshake requires an authenticated
323
- * CoreMail session on this exact connection. The ops TypedSocket surface is
324
- * shared with other client types, so presence on the socket proves nothing.
396
+ * CoreMail session on this exact connection. The Ops TypedSocket surface is
397
+ * shared with other client types and the gateway listener takes any peer that
398
+ * reaches it, so presence on a socket proves nothing.
325
399
  */
326
400
  private requireSession(peerArg: ICoreMailPeerLike): ICoreMailGatewaySession {
327
401
  const session = peerArg.data.get(coreMailGatewaySessionKey) as
@@ -353,7 +427,12 @@ export class CoreMailGatewayManager {
353
427
  bucket.refilledAt += intervals * 1_000;
354
428
  }
355
429
  if (bucket.tokens < 1) {
356
- throw contractError('AUTHENTICATION_FAILED');
430
+ // A spent budget says nothing about the credential, so the refusal is
431
+ // retryable once the next token refills.
432
+ throw contractError(
433
+ 'AUTHENTICATION_FAILED',
434
+ Math.max(1, Math.ceil(bucket.refilledAt + 1_000 - now)),
435
+ );
357
436
  }
358
437
  bucket.tokens -= 1;
359
438
  }
@@ -515,7 +594,7 @@ export class CoreMailGatewayManager {
515
594
  || existing.submissionDigest !== submissionDigest
516
595
  || existing.rawMimeSha256 !== message.rawMime.sha256
517
596
  || existing.rawMimeLengthBytes !== message.rawMime.lengthBytes
518
- || JSON.stringify(existing.envelope) !== JSON.stringify(message.envelope)
597
+ || !isSameCoreMailEnvelope(existing.envelope, message.envelope)
519
598
  ) {
520
599
  throw contractError('IDEMPOTENCY_CONFLICT');
521
600
  }
@@ -929,7 +1008,7 @@ export class CoreMailGatewayManager {
929
1008
  if (!connection || !session || session.coreMailServiceId !== coreMailServiceIdArg) {
930
1009
  throw new Error('No authenticated CoreMail gateway peer is connected');
931
1010
  }
932
- return await this.runtime.createRequest<T>(methodArg, connection).fire(requestArg);
1011
+ return await connection.fire<T>(methodArg, requestArg);
933
1012
  }
934
1013
 
935
1014
  private async isPeerConnected(coreMailServiceIdArg: string): Promise<boolean> {
@@ -1,13 +1,26 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import type { DcRouter } from '../classes.dcrouter.js';
3
- import type { ICoreMailGatewayRuntime } from './classes.coremail-gateway.manager.js';
3
+ import type {
4
+ ICoreMailGatewayConnection,
5
+ ICoreMailGatewayRuntime,
6
+ } from './classes.coremail-gateway.manager.js';
7
+
8
+ /** A CoreMail connection on a TypedSocket 8 server, with requests bound to that socket. */
9
+ export const toCoreMailGatewayConnection = (
10
+ typedsocketArg: plugins.typedsocket.TypedSocket,
11
+ connectionArg: plugins.typedsocket.ISmartServeConnectionWrapper,
12
+ ): ICoreMailGatewayConnection => ({
13
+ peer: connectionArg.peer,
14
+ fire: async (methodArg, requestArg) =>
15
+ await typedsocketArg.createTypedRequest(methodArg, connectionArg).fire(requestArg),
16
+ });
4
17
 
5
18
  /**
6
19
  * Binds the CoreMail gateway manager to the live dcrouter runtime.
7
20
  *
8
21
  * Everything the manager needs from dcrouter passes through this adapter, so
9
22
  * the manager stays testable against a stub and dcrouter keeps ownership of
10
- * the smartmta server and the ops TypedSocket.
23
+ * the smartmta server and the sockets the gateway is served on.
11
24
  */
12
25
  export const createCoreMailGatewayRuntime = (
13
26
  dcRouterArg: DcRouter,
@@ -28,20 +41,14 @@ export const createCoreMailGatewayRuntime = (
28
41
 
29
42
  getQueueItem: (queueItemIdArg) => dcRouterArg.emailServer?.getQueueItem(queueItemIdArg),
30
43
 
44
+ // The CoreMail gateway listener first: a CoreMail on TypedSocket 9 or 10 can
45
+ // reach only that one. A CoreMail on TypedSocket 8 reaches only the Ops server.
31
46
  findConnection: async (tagArg) => {
47
+ const listenerConnection = await dcRouterArg.opsServer?.coreMailGatewayServer?.findConnection(tagArg);
48
+ if (listenerConnection) return listenerConnection;
32
49
  const typedsocket = dcRouterArg.opsServer?.server?.typedserver?.typedsocket;
33
50
  if (!typedsocket) return undefined;
34
- return await typedsocket.findTargetConnectionByTag(tagArg as never) as never;
35
- },
36
-
37
- createRequest: (methodArg, connectionArg) => {
38
- const typedsocket = dcRouterArg.opsServer?.server?.typedserver?.typedsocket;
39
- if (!typedsocket) {
40
- throw new Error('OpsServer TypedSocket is not available for CoreMail gateway requests');
41
- }
42
- return typedsocket.createTypedRequest(
43
- methodArg as never,
44
- connectionArg as plugins.typedsocket.ISmartServeConnectionWrapper,
45
- ) as never;
51
+ const connection = await typedsocket.findTargetConnectionByTag(tagArg);
52
+ return connection ? toCoreMailGatewayConnection(typedsocket, connection) : undefined;
46
53
  },
47
54
  });
@@ -0,0 +1,104 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type {
3
+ CoreMailGatewayManager,
4
+ ICoreMailGatewayConnection,
5
+ } from '../email/classes.coremail-gateway.manager.js';
6
+
7
+ /**
8
+ * The one path the listener serves. The URL CoreMail dials, Cloudly's
9
+ * `coreMailGatewayEndpointUrl`, is a bare wss origin, so the socket sits at the
10
+ * root of the hub hostname that routes to this listener.
11
+ */
12
+ export const COREMAIL_GATEWAY_SOCKET_PATH = '/';
13
+
14
+ /** Opt-in loopback listener (Linus ruling 70, L2). Its hub hostname route is the operator's. */
15
+ export interface ICoreMailGatewayServerOptions {
16
+ /** Port on 127.0.0.1; 0 asks the OS for one. */
17
+ port: number;
18
+ /** Hostnames of the hub route that forwards to this port; any other Host is answered 404. */
19
+ hostnames: readonly string[];
20
+ }
21
+
22
+ type TCoreMailGatewayTypedServer = plugins.typedserverCoreMail.TypedServer;
23
+
24
+ /**
25
+ * The CoreMail gateway on wire 9 (TypedSocket 9 and 10), separate from the Ops
26
+ * server, which keeps serving it on TypedSocket 8. It serves the gateway's
27
+ * three handlers and nothing else: no Ops router, admin or workload mail, no
28
+ * HTTP RPC, no client tags and no built-in methods. Authentication is the
29
+ * gateway's own, the CoreMail credential behind the per-credential-id attempt
30
+ * limiter; the listener adds no source check.
31
+ */
32
+ export class CoreMailGatewayServer {
33
+ private server?: TCoreMailGatewayTypedServer;
34
+ /** Peers are looked up only between a completed start and the beginning of stop. */
35
+ private serving = false;
36
+
37
+ constructor(
38
+ private readonly options: ICoreMailGatewayServerOptions,
39
+ private readonly manager: Pick<CoreMailGatewayManager, 'registerGatewayHandlers'>,
40
+ ) {}
41
+
42
+ public get listeningPort(): number | null {
43
+ return this.server?.listeningPort ?? null;
44
+ }
45
+
46
+ public async start(): Promise<void> {
47
+ if (this.server) throw new Error('CoreMail gateway server already started');
48
+ if (!Number.isInteger(this.options.port) || this.options.port < 0 || this.options.port > 65535) {
49
+ throw new Error('CoreMail gateway port must be an integer from 0 to 65535');
50
+ }
51
+ const router = new plugins.typedrequestCoreMail.TypedRouter();
52
+ this.manager.registerGatewayHandlers(router);
53
+ const server = new plugins.typedserverCoreMail.TypedServer({
54
+ port: this.options.port,
55
+ listenHostname: '127.0.0.1',
56
+ cors: false,
57
+ surfaces: [{
58
+ name: 'coremail-gateway',
59
+ match: { hostnames: this.options.hostnames, pathPrefixes: [COREMAIL_GATEWAY_SOCKET_PATH] },
60
+ websocketTypedRouter: router,
61
+ typedRequestPath: false,
62
+ includeBuiltinTypedHandlers: false,
63
+ healthzEndpoint: false,
64
+ cors: false,
65
+ requestAdmission: () => new Response('Not Found', { status: 404 }),
66
+ websocketAdmission: (contextArg) => (
67
+ contextArg.url.pathname === COREMAIL_GATEWAY_SOCKET_PATH
68
+ && !contextArg.url.search
69
+ && !contextArg.request.headers.has('origin')
70
+ ) ? true : new Response('Not Found', { status: 404 }),
71
+ }],
72
+ });
73
+ this.server = server;
74
+ try {
75
+ await server.start();
76
+ } catch (error) {
77
+ try { await this.stop(); } catch (cleanupError) {
78
+ throw new AggregateError([error, cleanupError], 'CoreMail gateway startup and cleanup failed');
79
+ }
80
+ throw error;
81
+ }
82
+ this.serving = true;
83
+ }
84
+
85
+ /** The CoreMail connection with this routing tag on this listener, requests bound to its socket. */
86
+ public async findConnection(tagArg: string): Promise<ICoreMailGatewayConnection | undefined> {
87
+ const server = this.server;
88
+ if (!server || !this.serving) return undefined;
89
+ const connection = await server.typedsocket.findTargetConnectionByTag(tagArg);
90
+ if (!connection) return undefined;
91
+ return {
92
+ peer: connection.peer,
93
+ fire: async (methodArg, requestArg) =>
94
+ await server.typedsocket.createTypedRequest(methodArg, connection).fire(requestArg),
95
+ };
96
+ }
97
+
98
+ public async stop(): Promise<void> {
99
+ this.serving = false;
100
+ const server = this.server;
101
+ await server?.stop();
102
+ if (this.server === server) this.server = undefined;
103
+ }
104
+ }
@@ -8,6 +8,7 @@ import * as interfaces from '../../ts_interfaces/index.js';
8
8
  import { McpManager } from './classes.mcpmanager.js';
9
9
  import { createLogStreamAuthorizationAdapter } from './helpers/logstream-auth.js';
10
10
  import { WorkloadMailServer } from './classes.workload-mail-server.js';
11
+ import { CoreMailGatewayServer } from './classes.coremail-gateway-server.js';
11
12
  import type { IListeningSocket } from '../config/helpers.listeners.js';
12
13
 
13
14
  export class OpsServer {
@@ -58,6 +59,8 @@ export class OpsServer {
58
59
  // Detach the edges start() adds to the dcRouter's main typedrouter, which outlives this server.
59
60
  private dcRouterEdgeDisposers: Array<() => void> = [];
60
61
  private workloadMailServer?: WorkloadMailServer;
62
+ /** The CoreMail gateway listener (wire 9) while it runs; absent unless configured. */
63
+ public coreMailGatewayServer?: CoreMailGatewayServer;
61
64
  /** The port the Ops server listens on from its completed start until its stop begins. */
62
65
  private listeningPort?: number;
63
66
 
@@ -67,8 +70,8 @@ export class OpsServer {
67
70
 
68
71
  /**
69
72
  * The Ops server's socket once its start completed: typedserver binds every address (`0.0.0.0`)
70
- * and refuses `start()` on an address another socket holds. The workload mail listener binds
71
- * 127.0.0.1 only and is never public, so it is not listed.
73
+ * and refuses `start()` on an address another socket holds. The workload mail and CoreMail
74
+ * gateway listeners bind 127.0.0.1 only and are never public, so they are not listed.
72
75
  */
73
76
  public getListeningSockets(): IListeningSocket[] {
74
77
  return this.listeningPort === undefined
@@ -107,6 +110,13 @@ export class OpsServer {
107
110
  );
108
111
  await this.workloadMailServer.start();
109
112
  }
113
+ if (this.dcRouterRef.options.coreMailGatewayServer) {
114
+ this.coreMailGatewayServer = new CoreMailGatewayServer(
115
+ this.dcRouterRef.options.coreMailGatewayServer,
116
+ this.dcRouterRef.coreMailGatewayManager,
117
+ );
118
+ await this.coreMailGatewayServer.start();
119
+ }
110
120
  } catch (startError) {
111
121
  // Nobody stops a server whose start failed (ServiceManager rolls back only the services that
112
122
  // started), so undo this start here and keep its error first.
@@ -241,6 +251,10 @@ export class OpsServer {
241
251
  await this.workloadMailServer?.stop();
242
252
  this.workloadMailServer = undefined;
243
253
  }),
254
+ attempt(async () => {
255
+ await this.coreMailGatewayServer?.stop();
256
+ this.coreMailGatewayServer = undefined;
257
+ }),
244
258
  attempt(() => this.server?.stop()),
245
259
  attempt(() => this.logsHandler?.cleanup()),
246
260
  ]);
package/ts/plugins.ts CHANGED
@@ -55,6 +55,18 @@ export {
55
55
  typedsocket,
56
56
  }
57
57
 
58
+ // The CoreMail gateway listener's wire (Linus ruling 70, L1 and L2): TypedServer 16 with its
59
+ // TypedSocket 10 (wire major 9) and the TypedRequest 9 copy that TypedServer resolves, under aliases
60
+ // only `CoreMailGatewayServer` and the gateway handlers it serves use. Everything else stays on the
61
+ // TypedSocket 8 packages above.
62
+ import * as typedrequestCoreMail from '@api.global/typedrequest-coremail';
63
+ import * as typedserverCoreMail from '@api.global/typedserver-coremail';
64
+
65
+ export {
66
+ typedrequestCoreMail,
67
+ typedserverCoreMail,
68
+ }
69
+
58
70
  // @idp.global scope
59
71
  import * as idpSdkServer from '@idp.global/sdk/server';
60
72
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '34.6.0',
6
+ version: '34.7.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }