@serve.zone/platformclient 3.0.1 → 4.1.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.
@@ -15,6 +15,8 @@ export interface ISzPlatformClientOptions {
15
15
  token?: string;
16
16
  url?: string;
17
17
  platformUrl?: string;
18
+ /** Explicit HTTPS Cloudly origin. Bindings never select this authority. */
19
+ cloudlyUrl?: string;
18
20
  binding?: plugins.servezoneInterfaces.platform.IPlatformBinding;
19
21
  bindings?: plugins.servezoneInterfaces.platform.IPlatformBinding[];
20
22
  coreMail?: ISzCoreMailConnectorOptions;
@@ -26,7 +28,13 @@ export class SzPlatformClient {
26
28
  private connectionAddress?: string;
27
29
  public platformBindings: plugins.servezoneInterfaces.platform.IPlatformBinding[] = [];
28
30
  public typedrouter: plugins.typedrequest.TypedRouter = new plugins.typedrequest.TypedRouter();
29
- public typedsocket!: plugins.typedsocket.TypedSocket;
31
+ public typedsocket?: plugins.typedsocket.TypedSocket;
32
+ private identity?: plugins.servezoneInterfaces.data.IIdentity;
33
+ private authenticationGeneration = 0;
34
+ private authenticationAbort?: AbortController;
35
+ private authenticationOperations = new Set<Promise<unknown>>();
36
+ private renewalTimer?: ReturnType<typeof setTimeout>;
37
+ private statusSubscription?: { unsubscribe(): void };
30
38
  private qenvInstance = new plugins.qenv.Qenv();
31
39
  private stopped = false;
32
40
  private initPromise?: Promise<void>;
@@ -67,6 +75,7 @@ export class SzPlatformClient {
67
75
  return;
68
76
  }
69
77
 
78
+ await this.configureCoreMailFromEnv();
70
79
  this.platformBindings = this.mergePlatformBindings(
71
80
  this.platformBindings,
72
81
  await this.discoverPlatformBindings()
@@ -77,25 +86,41 @@ export class SzPlatformClient {
77
86
  return;
78
87
  }
79
88
  if (!this.authorizationString) throw new Error('authorizationString is required');
89
+ if (this.authorizationString.length > 8192 || /[\x00-\x20\x7f]/.test(this.authorizationString)) {
90
+ throw new Error('Platform authorization is invalid.');
91
+ }
80
92
  const socket = await plugins.typedsocket.TypedSocket.createClient(
81
- this.typedrouter as any,
93
+ this.typedrouter,
82
94
  await this.getConnectionAddress(),
83
- { abortSignal: this.platformSocketAbortController.signal },
95
+ {
96
+ autoReconnect: true, maxRetries: 100, initialBackoffMs: 1000, maxBackoffMs: 30_000,
97
+ abortSignal: this.platformSocketAbortController.signal,
98
+ restoreConnection: (context) => {
99
+ this.invalidateAuthentication();
100
+ const generation = this.authenticationGeneration;
101
+ return this.trackAuthentication(this.authenticate(context.createTypedRequest, context.abortSignal, generation));
102
+ },
103
+ },
84
104
  );
85
105
  if (this.stopped) {
86
- await socket.stop().catch(() => undefined);
106
+ await socket.stop();
87
107
  throw this.createAbortError();
88
108
  }
89
109
  this.typedsocket = socket;
90
110
  try {
91
- await this.setAuthorizationTag();
111
+ this.statusSubscription = socket.statusSubject.subscribe((status) => {
112
+ if (status === 'disconnected') this.invalidateAuthentication();
113
+ if (status === 'connected') this.scheduleRenewal(socket);
114
+ });
115
+ this.scheduleRenewal(socket);
92
116
  this.assertRunning();
93
117
  if (this.coreMailConnector.isConfigured) {
94
118
  await this.coreMailConnector.init();
95
119
  this.assertRunning();
96
120
  }
97
121
  } catch (error) {
98
- await socket.stop().catch(() => undefined);
122
+ this.invalidateAuthentication();
123
+ await socket.stop();
99
124
  throw error;
100
125
  }
101
126
  }
@@ -131,7 +156,7 @@ export class SzPlatformClient {
131
156
  optionsArg.authorization ||
132
157
  optionsArg.token ||
133
158
  this.authorizationString;
134
- this.connectionAddress = optionsArg.url || optionsArg.platformUrl || this.connectionAddress;
159
+ this.connectionAddress = optionsArg.cloudlyUrl || optionsArg.url || optionsArg.platformUrl || this.connectionAddress;
135
160
  this.platformBindings = this.mergePlatformBindings(
136
161
  this.platformBindings,
137
162
  [optionsArg.binding, ...(optionsArg.bindings || [])].filter(
@@ -146,34 +171,12 @@ export class SzPlatformClient {
146
171
  private async getConnectionAddress() {
147
172
  const connectionAddress =
148
173
  this.connectionAddress ||
149
- (await this.getConfiguredEnvVar('SERVEZONE_PLATFORM_URL')) ||
150
- this.getConnectionAddressFromBindings();
151
- if (!connectionAddress) throw new Error('SERVEZONE_PLATFORM_URL or platform binding endpoint is required');
152
- return connectionAddress;
153
- }
154
-
155
- private getConnectionAddressFromBindings() {
156
- for (const binding of this.platformBindings) {
157
- if (binding.desiredState === 'disabled' || binding.status === 'failed') {
158
- continue;
159
- }
160
- if (!this.isSupportedConnectorCapability(binding.capability)) {
161
- continue;
162
- }
163
- const endpoint = binding.endpoints?.find((endpointArg) => {
164
- return endpointArg.protocol === 'typedrequest' || endpointArg.protocol === 'http';
165
- });
166
- const endpointUrl = endpoint?.internalUrl || endpoint?.externalUrl;
167
- if (endpointUrl) {
168
- return endpointUrl;
169
- }
170
- }
171
- }
172
-
173
- private isSupportedConnectorCapability(
174
- capabilityArg: plugins.servezoneInterfaces.platform.TPlatformCapability
175
- ) {
176
- return ['email', 'sms', 'pushnotification', 'letter'].includes(capabilityArg);
174
+ (await this.getConfiguredEnvVar('SERVEZONE_PLATFORM_URL'));
175
+ if (!connectionAddress) throw new Error('An explicit Cloudly URL is required.');
176
+ const parsed = new URL(connectionAddress);
177
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash
178
+ || parsed.pathname !== '/') throw new Error('Cloudly URL must be an HTTPS origin.');
179
+ return parsed.origin;
177
180
  }
178
181
 
179
182
  private async getConfiguredAuthorizationString() {
@@ -183,19 +186,81 @@ export class SzPlatformClient {
183
186
  );
184
187
  }
185
188
 
186
- private async setAuthorizationTag() {
187
- if (!this.authorizationString) {
188
- return;
189
- }
189
+ public requireTypedSocket(): plugins.typedsocket.TypedSocket {
190
+ this.assertRunning();
191
+ if (!this.typedsocket || this.typedsocket.getStatus() !== 'connected' || !this.identity
192
+ || this.identity.expiresAt <= Date.now()) throw new Error('Cloudly platform session is unavailable.');
193
+ return this.typedsocket;
194
+ }
195
+
196
+ private invalidateAuthentication(): void {
197
+ this.authenticationGeneration++;
198
+ this.authenticationAbort?.abort();
199
+ this.authenticationAbort = undefined;
200
+ this.identity = undefined;
201
+ if (this.renewalTimer) clearTimeout(this.renewalTimer);
202
+ this.renewalTimer = undefined;
203
+ }
204
+
205
+ private trackAuthentication<T>(operation: Promise<T>): Promise<T> {
206
+ this.authenticationOperations.add(operation);
207
+ void operation.then(() => this.authenticationOperations.delete(operation), () => this.authenticationOperations.delete(operation));
208
+ return operation;
209
+ }
210
+
211
+ private async authenticate(createRequest: plugins.typedsocket.TTypedSocketRestoreRequestFactory,
212
+ signal: AbortSignal, generation: number): Promise<void> {
213
+ const requireCurrent = () => {
214
+ this.assertRunning(); signal.throwIfAborted();
215
+ if (generation !== this.authenticationGeneration) throw this.createAbortError();
216
+ };
190
217
  try {
191
- await this.typedsocket.setTag('authorization' as any, this.authorizationString);
192
- } catch (error) {
193
- if (await this.getConfiguredEnvVar('SERVEZONE_PLATFORMCLIENT_DEBUG')) {
194
- console.warn('Could not set platform authorization tag:', (error as Error).message);
218
+ requireCurrent();
219
+ const exchange = createRequest<plugins.servezoneInterfaces.requests.identity.IRequest_Any_Cloudly_CoreflowManager_GetIdentityByToken>(
220
+ 'getIdentityByToken');
221
+ exchange.skipHooks = true;
222
+ const response = await exchange.fire({ token: this.authorizationString! }, { maxRetries: 0, abortSignal: signal });
223
+ requireCurrent();
224
+ const identity = response.identity;
225
+ if (!identity || identity.type !== 'machine' || identity.role !== 'api'
226
+ || typeof identity.userId !== 'string' || !/^service-machine:[a-f0-9]{64}$/.test(identity.userId)
227
+ || !Number.isSafeInteger(identity.expiresAt) || identity.expiresAt <= Date.now() + 1000) {
228
+ throw new Error('Invalid platform identity.');
195
229
  }
230
+ const requestData = plugins.servezoneInterfaces.data.snapshotRegisterCloudlyClientSessionRequestV1({
231
+ schemaVersion: 1, identity: { jwt: identity.jwt },
232
+ });
233
+ const registration = createRequest<plugins.servezoneInterfaces.requests.runtimesession.IReq_Any_Cloudly_RegisterCloudlyClientSessionV1>(
234
+ 'registerCloudlyClientSessionV1');
235
+ registration.skipHooks = true;
236
+ const registered = await registration.fire(requestData, { maxRetries: 0, abortSignal: signal });
237
+ plugins.servezoneInterfaces.data.snapshotRegisterCloudlyClientSessionResponseV1(registered);
238
+ requireCurrent();
239
+ this.identity = { type: identity.type, role: identity.role, userId: identity.userId,
240
+ name: identity.userId, expiresAt: identity.expiresAt, jwt: requestData.identity.jwt };
241
+ } catch {
242
+ if (this.stopped || signal.aborted || generation !== this.authenticationGeneration) throw this.createAbortError();
243
+ throw new Error('Cloudly platform authentication failed.');
196
244
  }
197
245
  }
198
246
 
247
+ private scheduleRenewal(socket: plugins.typedsocket.TypedSocket): void {
248
+ if (this.renewalTimer) clearTimeout(this.renewalTimer);
249
+ if (this.stopped || this.typedsocket !== socket || !this.identity) return;
250
+ const generation = this.authenticationGeneration;
251
+ const remaining = this.identity.expiresAt - Date.now();
252
+ this.renewalTimer = setTimeout(() => {
253
+ this.renewalTimer = undefined;
254
+ if (this.stopped || this.typedsocket !== socket || generation !== this.authenticationGeneration) return;
255
+ const abort = new AbortController(); this.authenticationAbort = abort;
256
+ const work = this.trackAuthentication(this.authenticate(
257
+ (method) => socket.createTypedRequest(method), abort.signal, generation));
258
+ void work.then(() => this.scheduleRenewal(socket), () => {
259
+ if (!this.stopped && generation === this.authenticationGeneration) void this.stop().catch(() => undefined);
260
+ });
261
+ }, Math.max(1, Math.min(30 * 60 * 1000, Math.floor(remaining / 2))));
262
+ }
263
+
199
264
  public stop(): Promise<void> {
200
265
  this.stopPromise ??= this.stopInner();
201
266
  return this.stopPromise;
@@ -203,21 +268,26 @@ export class SzPlatformClient {
203
268
 
204
269
  private async stopInner(): Promise<void> {
205
270
  this.stopped = true;
271
+ this.invalidateAuthentication();
272
+ this.statusSubscription?.unsubscribe();
273
+ this.statusSubscription = undefined;
206
274
  this.platformSocketAbortController.abort(this.createAbortError());
207
275
  const socket = this.typedsocket;
208
- await Promise.allSettled([
276
+ this.typedsocket = undefined;
277
+ const results = await Promise.allSettled([
209
278
  this.emailConnector.stop(),
210
279
  this.webPushConnector.stop(),
211
280
  this.coreMailConnector.stop(),
212
281
  socket?.stop(),
213
- this.initPromise,
214
282
  ]);
215
- if (this.typedsocket && this.typedsocket !== socket) {
216
- await this.typedsocket.stop().catch(() => undefined);
217
- }
283
+ await this.initPromise?.catch(() => undefined);
284
+ while (this.authenticationOperations.size) await Promise.allSettled([...this.authenticationOperations]);
285
+ const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
286
+ if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Platform client shutdown failed.');
218
287
  }
219
288
 
220
- private assertRunning(): void {
289
+ /** Connector admission guard; shutdown is terminal for this client lifetime. */
290
+ public assertRunning(): void {
221
291
  if (this.stopped) {
222
292
  throw this.createAbortError();
223
293
  }
@@ -229,6 +299,45 @@ export class SzPlatformClient {
229
299
  return error;
230
300
  }
231
301
 
302
+ /**
303
+ * Configures the CoreMail connector from the unscoped MAIL_COREMAIL_* group
304
+ * when no programmatic `coreMail` option was supplied. Per-address groups
305
+ * (MAIL_<TOKEN>_COREMAIL_*) are resolved by the email connector at send time;
306
+ * this only establishes the default binding so init() can authenticate early.
307
+ * A partially configured group throws rather than starting unconfigured.
308
+ */
309
+ private async configureCoreMailFromEnv(): Promise<void> {
310
+ if (this.coreMailConnector.isConfigured) return;
311
+ const endpointUrl = await this.getConfiguredEnvVar('MAIL_COREMAIL_URL');
312
+ const bindingId = await this.getConfiguredEnvVar('MAIL_COREMAIL_BINDING_ID');
313
+ const credentialId = await this.getConfiguredEnvVar('MAIL_COREMAIL_CREDENTIAL_ID');
314
+ const credentialVersion = await this.getConfiguredEnvVar('MAIL_COREMAIL_CREDENTIAL_VERSION');
315
+ const credentialSecret = await this.getConfiguredEnvVar('MAIL_COREMAIL_CREDENTIAL_SECRET');
316
+ const present = [endpointUrl, bindingId, credentialId, credentialVersion, credentialSecret];
317
+ if (!present.some(Boolean)) return;
318
+ const missing = [
319
+ ...(endpointUrl ? [] : ['MAIL_COREMAIL_URL']),
320
+ ...(bindingId ? [] : ['MAIL_COREMAIL_BINDING_ID']),
321
+ ...(credentialId ? [] : ['MAIL_COREMAIL_CREDENTIAL_ID']),
322
+ ...(credentialVersion ? [] : ['MAIL_COREMAIL_CREDENTIAL_VERSION']),
323
+ ...(credentialSecret ? [] : ['MAIL_COREMAIL_CREDENTIAL_SECRET']),
324
+ ];
325
+ if (missing.length > 0) {
326
+ throw new Error(`CoreMail is partially configured. Missing: ${missing.join(', ')}`);
327
+ }
328
+ const parsedVersion = Number(credentialVersion);
329
+ if (!Number.isSafeInteger(parsedVersion) || parsedVersion < 1) {
330
+ throw new Error('MAIL_COREMAIL_CREDENTIAL_VERSION must be a positive integer');
331
+ }
332
+ this.coreMailConnector.configure({
333
+ endpointUrl: endpointUrl!,
334
+ bindingId: bindingId!,
335
+ credentialId: credentialId!,
336
+ credentialVersion: parsedVersion,
337
+ credentialSecret: credentialSecret!,
338
+ });
339
+ }
340
+
232
341
  private async discoverPlatformBindings() {
233
342
  const bindings: plugins.servezoneInterfaces.platform.IPlatformBinding[] = [];
234
343
  const envBindings = await this.getConfiguredEnvVar('SERVEZONE_PLATFORM_BINDINGS');
@@ -19,6 +19,29 @@ export interface ISzCoreMailConnectorOptions {
19
19
  credentialSecret: string;
20
20
  }
21
21
 
22
+ /** Default cadence for the pull-based inbound loop. */
23
+ export const szCoreMailInboundPollDefaults = Object.freeze({
24
+ intervalMs: 5_000,
25
+ minimumIntervalMs: 250,
26
+ maximumBackoffMs: 120_000,
27
+ pageLimit: 25,
28
+ });
29
+
30
+ export interface ISzCoreMailInboundPollOptions {
31
+ /** Tick cadence in milliseconds; defaults to 5000, floored at 250. */
32
+ intervalMs?: number;
33
+ /** Deliveries requested per tick; defaults to 25. */
34
+ pageLimit?: number;
35
+ }
36
+
37
+ /**
38
+ * Invoked once per pending delivery. Resolving acknowledges the delivery as
39
+ * `processed`; throwing leaves it pending for a later tick.
40
+ */
41
+ export type TSzCoreMailInboundHandler = (
42
+ resultArg: ISzCoreMailInboundFetchResult,
43
+ ) => Promise<void> | void;
44
+
22
45
  export interface ISzCoreMailUploadPartOptions {
23
46
  submissionId: string;
24
47
  part: plugins.servezoneInterfaces.data.ICoreMailContentDescriptor;
@@ -70,11 +93,37 @@ export class SzCoreMailConnector {
70
93
  private readonly stopController = new AbortController();
71
94
  private readonly activeOperations = new Set<Promise<unknown>>();
72
95
  private stopPromise?: Promise<void>;
96
+ private inboundHandler?: TSzCoreMailInboundHandler;
97
+ private inboundPollOptions = { intervalMs: 0, pageLimit: 0 };
98
+ private inboundTimer?: ReturnType<typeof setTimeout>;
99
+ private inboundTickPromise?: Promise<void>;
100
+ private inboundConsecutiveFailures = 0;
73
101
 
74
102
  public get isConfigured(): boolean {
75
103
  return Boolean(this.options);
76
104
  }
77
105
 
106
+ /**
107
+ * Value-free identity of the binding this connector is configured for, or
108
+ * undefined while unconfigured. Deliberately excludes credentialSecret: it
109
+ * exists so a caller holding several bindings can tell whether this instance
110
+ * already serves one of them, which never requires the secret.
111
+ */
112
+ public get configuredBinding(): Readonly<{
113
+ endpointUrl: string;
114
+ bindingId: string;
115
+ credentialId: string;
116
+ credentialVersion: number;
117
+ }> | undefined {
118
+ if (!this.options) return undefined;
119
+ return Object.freeze({
120
+ endpointUrl: this.options.endpointUrl,
121
+ bindingId: this.options.bindingId,
122
+ credentialId: this.options.credentialId,
123
+ credentialVersion: this.options.credentialVersion,
124
+ });
125
+ }
126
+
78
127
  public configure(optionsArg: ISzCoreMailConnectorOptions): void {
79
128
  if (this.stopped) {
80
129
  throw new Error('CoreMail connector has stopped.');
@@ -181,18 +230,144 @@ export class SzCoreMailConnector {
181
230
  };
182
231
  }
183
232
 
233
+ /**
234
+ * Starts the bounded pull loop (list -> fetch -> acknowledge). CoreMail never
235
+ * pushes inbound mail, so the workload polls. One registration owns the loop;
236
+ * re-registering replaces the handler without starting a second timer.
237
+ */
238
+ public startInboundPolling(
239
+ handlerArg: TSzCoreMailInboundHandler,
240
+ optionsArg: ISzCoreMailInboundPollOptions = {},
241
+ ): void {
242
+ this.requireOptions();
243
+ if (typeof handlerArg !== 'function') {
244
+ throw new Error('CoreMail inbound handler must be a function.');
245
+ }
246
+ const requestedInterval = optionsArg.intervalMs ?? szCoreMailInboundPollDefaults.intervalMs;
247
+ if (!Number.isSafeInteger(requestedInterval) || requestedInterval < szCoreMailInboundPollDefaults.minimumIntervalMs) {
248
+ throw new Error(
249
+ `CoreMail inbound intervalMs must be an integer of at least ${szCoreMailInboundPollDefaults.minimumIntervalMs}.`,
250
+ );
251
+ }
252
+ const requestedPageLimit = optionsArg.pageLimit ?? szCoreMailInboundPollDefaults.pageLimit;
253
+ if (
254
+ !Number.isSafeInteger(requestedPageLimit)
255
+ || requestedPageLimit < 1
256
+ || requestedPageLimit > plugins.servezoneInterfaces.data.coreMailLimits.inboundPageSize
257
+ ) {
258
+ throw new Error(
259
+ `CoreMail inbound pageLimit must be an integer between 1 and ${plugins.servezoneInterfaces.data.coreMailLimits.inboundPageSize}.`,
260
+ );
261
+ }
262
+ this.inboundHandler = handlerArg;
263
+ this.inboundPollOptions = { intervalMs: requestedInterval, pageLimit: requestedPageLimit };
264
+ this.inboundConsecutiveFailures = 0;
265
+ if (!this.inboundTimer && !this.inboundTickPromise) {
266
+ this.scheduleInboundTick(0);
267
+ }
268
+ }
269
+
270
+ /** Stops the pull loop and awaits the tick in flight. */
271
+ public async stopInboundPolling(): Promise<void> {
272
+ this.inboundHandler = undefined;
273
+ if (this.inboundTimer) {
274
+ clearTimeout(this.inboundTimer);
275
+ this.inboundTimer = undefined;
276
+ }
277
+ await this.inboundTickPromise?.catch(() => undefined);
278
+ }
279
+
184
280
  public stop(): Promise<void> {
185
281
  this.stopPromise ??= this.stopInner();
186
282
  return this.stopPromise;
187
283
  }
188
284
 
285
+ private scheduleInboundTick(delayMsArg: number): void {
286
+ if (this.stopped || !this.inboundHandler) return;
287
+ this.inboundTimer = setTimeout(() => {
288
+ this.inboundTimer = undefined;
289
+ // One tick at a time: the next tick is only scheduled once this one settles.
290
+ const tick = this.runInboundTick().catch(() => undefined).finally(() => {
291
+ if (this.inboundTickPromise === tick) this.inboundTickPromise = undefined;
292
+ this.scheduleInboundTick(this.nextInboundDelayMs());
293
+ });
294
+ this.inboundTickPromise = tick;
295
+ }, delayMsArg);
296
+ this.inboundTimer.unref?.();
297
+ }
298
+
299
+ /** Exponential backoff on transport failure, capped; reset by a clean tick. */
300
+ private nextInboundDelayMs(): number {
301
+ if (this.inboundConsecutiveFailures === 0) return this.inboundPollOptions.intervalMs;
302
+ const backoff = this.inboundPollOptions.intervalMs * 2 ** Math.min(this.inboundConsecutiveFailures, 16);
303
+ return Math.min(backoff, szCoreMailInboundPollDefaults.maximumBackoffMs);
304
+ }
305
+
306
+ private async runInboundTick(): Promise<void> {
307
+ const handler = this.inboundHandler;
308
+ if (this.stopped || !handler) return;
309
+ try {
310
+ const page = await this.listInboundDeliveries({ limit: this.inboundPollOptions.pageLimit });
311
+ this.inboundConsecutiveFailures = 0;
312
+ for (const delivery of page.deliveries) {
313
+ if (this.stopped || this.inboundHandler !== handler) return;
314
+ if (delivery.state === 'acknowledged') continue;
315
+ let fetched: ISzCoreMailInboundFetchResult;
316
+ try {
317
+ fetched = await this.fetchInboundDelivery(delivery);
318
+ } catch (error) {
319
+ // Leave the delivery pending; a later tick retries the fetch.
320
+ this.inboundConsecutiveFailures++;
321
+ return;
322
+ }
323
+ try {
324
+ await handler(fetched);
325
+ } catch {
326
+ // A throwing handler must not acknowledge. The delivery stays pending.
327
+ continue;
328
+ }
329
+ await this.acknowledgeInboundDelivery({
330
+ deliveryId: fetched.delivery.deliveryId,
331
+ outcome: 'processed',
332
+ });
333
+ }
334
+ } catch (error) {
335
+ this.inboundConsecutiveFailures++;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Mirrors the loopback rule TypedSocket enforces before it downgrades a
341
+ * connection to an insecure WebSocket. See `isLoopbackHostname` in
342
+ * node_modules/@api.global/typedsocket/dist_ts/classes.typedsocket.js:257-267,
343
+ * applied at classes.typedsocket.js:986-988
344
+ * ('TypedSocket insecure WebSocket transport is restricted to loopback hosts.').
345
+ * Keeping the predicates identical means this connector never accepts an
346
+ * endpoint that TypedSocket would refuse one layer down.
347
+ */
348
+ private isLoopbackHostname(hostnameArg: string): boolean {
349
+ const hostname = hostnameArg.toLowerCase().replace(/^\[(.*)\]$/, '$1');
350
+ if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') {
351
+ return true;
352
+ }
353
+ const octets = hostname.split('.');
354
+ return octets.length === 4
355
+ && octets.every((octetArg) => /^\d{1,3}$/.test(octetArg) && Number(octetArg) <= 255)
356
+ && Number(octets[0]) === 127;
357
+ }
358
+
189
359
  private normalizeOptions(
190
360
  optionsArg: ISzCoreMailConnectorOptions,
191
361
  ): ISzCoreMailConnectorOptions {
192
362
  const endpointUrl = new URL(optionsArg.endpointUrl);
363
+ // TypedSocket maps http:/ws: onto ws: and https:/wss: onto wss:
364
+ // (classes.typedsocket.js:974-985), so the same four schemes are accepted
365
+ // here; the insecure pair stays restricted to loopback authorities.
366
+ const isSecureProtocol = endpointUrl.protocol === 'https:' || endpointUrl.protocol === 'wss:';
367
+ const isInsecureProtocol = endpointUrl.protocol === 'http:' || endpointUrl.protocol === 'ws:';
193
368
  if (
194
369
  optionsArg.endpointUrl.trim() !== optionsArg.endpointUrl
195
- || endpointUrl.protocol !== 'https:'
370
+ || (!isSecureProtocol && !isInsecureProtocol)
196
371
  || endpointUrl.username
197
372
  || endpointUrl.password
198
373
  || endpointUrl.search
@@ -202,6 +377,11 @@ export class SzCoreMailConnector {
202
377
  ) {
203
378
  throw new Error('CoreMail endpointUrl must be one canonical HTTPS /socket URL.');
204
379
  }
380
+ if (isInsecureProtocol && !this.isLoopbackHostname(endpointUrl.hostname)) {
381
+ throw new Error(
382
+ 'CoreMail insecure endpointUrl is restricted to loopback hosts.',
383
+ );
384
+ }
205
385
  for (const [name, value] of [
206
386
  ['bindingId', optionsArg.bindingId],
207
387
  ['credentialId', optionsArg.credentialId],
@@ -397,8 +577,13 @@ export class SzCoreMailConnector {
397
577
  throw new Error('CoreMail returned an invalid transfer origin.');
398
578
  }
399
579
  const url = new URL(valueArg);
580
+ // The transfer origin must follow the same scheme rule as endpointUrl: a
581
+ // loopback CoreMail reached over http: also issues http: transfer grants,
582
+ // so restricting this to https: would make loopback deployments unusable.
583
+ const isSecureProtocol = url.protocol === 'https:';
584
+ const isInsecureProtocol = url.protocol === 'http:';
400
585
  if (
401
- url.protocol !== 'https:'
586
+ (!isSecureProtocol && !isInsecureProtocol)
402
587
  || url.username
403
588
  || url.password
404
589
  || url.search
@@ -408,6 +593,9 @@ export class SzCoreMailConnector {
408
593
  ) {
409
594
  throw new Error('CoreMail returned a noncanonical transfer origin.');
410
595
  }
596
+ if (isInsecureProtocol && !this.isLoopbackHostname(url.hostname)) {
597
+ throw new Error('CoreMail returned an insecure non-loopback transfer origin.');
598
+ }
411
599
  return valueArg;
412
600
  }
413
601
 
@@ -689,7 +877,7 @@ export class SzCoreMailConnector {
689
877
  response.status !== 200
690
878
  || response.headers.get('content-type') !== grantArg.contentType
691
879
  || response.headers.get('content-length') !== String(grantArg.lengthBytes)
692
- || response.headers.get('cache-control') !== 'no-store'
880
+ || !this.forbidsStoringResponse(response.headers.get('cache-control'))
693
881
  || response.headers.get('x-content-type-options') !== 'nosniff'
694
882
  || !responseBody
695
883
  ) {
@@ -794,6 +982,22 @@ export class SzCoreMailConnector {
794
982
  return url.toString();
795
983
  }
796
984
 
985
+ /**
986
+ * A one-time transfer payload must never be cached, but the exact spelling of
987
+ * Cache-Control is not ours to dictate: CoreMail's transfer service sets
988
+ * 'no-store' and typedserver's `noCache: true` then expands it to the full
989
+ * directive set ('no-store, no-cache, must-revalidate, proxy-revalidate,
990
+ * max-age=0'). Requiring byte equality with 'no-store' rejected every real
991
+ * CoreMail response, so assert the directive is present instead.
992
+ */
993
+ private forbidsStoringResponse(cacheControlArg: string | null): boolean {
994
+ if (!cacheControlArg) return false;
995
+ return cacheControlArg
996
+ .split(',')
997
+ .map((directiveArg) => directiveArg.trim().toLowerCase())
998
+ .includes('no-store');
999
+ }
1000
+
797
1001
  private async createSha256(
798
1002
  bytesArg: Uint8Array,
799
1003
  ): Promise<plugins.servezoneInterfaces.data.TCoreMailSha256> {
@@ -906,6 +1110,14 @@ export class SzCoreMailConnector {
906
1110
  private async stopInner(): Promise<void> {
907
1111
  this.stopped = true;
908
1112
  this.generation++;
1113
+ this.inboundHandler = undefined;
1114
+ if (this.inboundTimer) {
1115
+ clearTimeout(this.inboundTimer);
1116
+ this.inboundTimer = undefined;
1117
+ }
1118
+ const inboundTick = this.inboundTickPromise;
1119
+ this.inboundTickPromise = undefined;
1120
+ await inboundTick?.catch(() => undefined);
909
1121
  this.stopController.abort(this.createAbortError());
910
1122
  const connection = this.connection;
911
1123
  const pendingConnection = this.pendingConnection;