@serve.zone/coremail 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist_ts/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CoreMail } from './classes.coremail.js';
2
+ import { logCoreMailFailure } from './coremail.log.js';
2
3
  export { CoreMail } from './classes.coremail.js';
3
4
  export { readCoreMailConfig } from './classes.config.js';
4
5
  export const runCli = async () => {
@@ -14,7 +15,9 @@ export const runCli = async () => {
14
15
  await coreMail.stop();
15
16
  process.exitCode = 0;
16
17
  }
17
- catch {
18
+ catch (error) {
19
+ await logCoreMailFailure('lifecycle', 'SHUTDOWN_FAILED', error)
20
+ .catch(() => undefined);
18
21
  process.exitCode = 1;
19
22
  }
20
23
  };
@@ -36,4 +39,4 @@ export const runCli = async () => {
36
39
  throw error;
37
40
  }
38
41
  };
39
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsUUFBUSxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFFakQsT0FBTyxFQUFFLFFBQVEsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBQ2pELE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBR3pELE1BQU0sQ0FBQyxNQUFNLE1BQU0sR0FBRyxLQUFLLElBQW1CLEVBQUU7SUFDOUMsTUFBTSxRQUFRLEdBQUcsSUFBSSxRQUFRLEVBQUUsQ0FBQztJQUNoQyxJQUFJLFFBQVEsR0FBRyxLQUFLLENBQUM7SUFDckIsTUFBTSxJQUFJLEdBQUcsS0FBSyxJQUFtQixFQUFFO1FBQ3JDLElBQUksUUFBUTtZQUFFLE9BQU87UUFDckIsUUFBUSxHQUFHLElBQUksQ0FBQztRQUNoQixPQUFPLENBQUMsR0FBRyxDQUFDLFFBQVEsRUFBRSxRQUFRLENBQUMsQ0FBQztRQUNoQyxPQUFPLENBQUMsR0FBRyxDQUFDLFNBQVMsRUFBRSxTQUFTLENBQUMsQ0FBQztRQUNsQyxJQUFJLENBQUM7WUFDSCxNQUFNLFFBQVEsQ0FBQyxJQUFJLEVBQUUsQ0FBQztZQUN0QixPQUFPLENBQUMsUUFBUSxHQUFHLENBQUMsQ0FBQztRQUN2QixDQUFDO1FBQUMsTUFBTSxDQUFDO1lBQ1AsT0FBTyxDQUFDLFFBQVEsR0FBRyxDQUFDLENBQUM7UUFDdkIsQ0FBQztJQUNILENBQUMsQ0FBQztJQUNGLE1BQU0sUUFBUSxHQUFHLEdBQUcsRUFBRTtRQUNwQixLQUFLLElBQUksRUFBRSxDQUFDO0lBQ2QsQ0FBQyxDQUFDO0lBQ0YsTUFBTSxTQUFTLEdBQUcsR0FBRyxFQUFFO1FBQ3JCLEtBQUssSUFBSSxFQUFFLENBQUM7SUFDZCxDQUFDLENBQUM7SUFDRixPQUFPLENBQUMsRUFBRSxDQUFDLFFBQVEsRUFBRSxRQUFRLENBQUMsQ0FBQztJQUMvQixPQUFPLENBQUMsRUFBRSxDQUFDLFNBQVMsRUFBRSxTQUFTLENBQUMsQ0FBQztJQUNqQyxJQUFJLENBQUM7UUFDSCxNQUFNLFFBQVEsQ0FBQyxLQUFLLEVBQUUsQ0FBQztJQUN6QixDQUFDO0lBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztRQUNmLE9BQU8sQ0FBQyxHQUFHLENBQUMsUUFBUSxFQUFFLFFBQVEsQ0FBQyxDQUFDO1FBQ2hDLE9BQU8sQ0FBQyxHQUFHLENBQUMsU0FBUyxFQUFFLFNBQVMsQ0FBQyxDQUFDO1FBQ2xDLE1BQU0sUUFBUSxDQUFDLElBQUksRUFBRSxDQUFDLEtBQUssQ0FBQyxHQUFHLEVBQUUsQ0FBQyxTQUFTLENBQUMsQ0FBQztRQUM3QyxNQUFNLEtBQUssQ0FBQztJQUNkLENBQUM7QUFDSCxDQUFDLENBQUMifQ==
42
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsUUFBUSxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFDakQsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFFdkQsT0FBTyxFQUFFLFFBQVEsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBQ2pELE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBR3pELE1BQU0sQ0FBQyxNQUFNLE1BQU0sR0FBRyxLQUFLLElBQW1CLEVBQUU7SUFDOUMsTUFBTSxRQUFRLEdBQUcsSUFBSSxRQUFRLEVBQUUsQ0FBQztJQUNoQyxJQUFJLFFBQVEsR0FBRyxLQUFLLENBQUM7SUFDckIsTUFBTSxJQUFJLEdBQUcsS0FBSyxJQUFtQixFQUFFO1FBQ3JDLElBQUksUUFBUTtZQUFFLE9BQU87UUFDckIsUUFBUSxHQUFHLElBQUksQ0FBQztRQUNoQixPQUFPLENBQUMsR0FBRyxDQUFDLFFBQVEsRUFBRSxRQUFRLENBQUMsQ0FBQztRQUNoQyxPQUFPLENBQUMsR0FBRyxDQUFDLFNBQVMsRUFBRSxTQUFTLENBQUMsQ0FBQztRQUNsQyxJQUFJLENBQUM7WUFDSCxNQUFNLFFBQVEsQ0FBQyxJQUFJLEVBQUUsQ0FBQztZQUN0QixPQUFPLENBQUMsUUFBUSxHQUFHLENBQUMsQ0FBQztRQUN2QixDQUFDO1FBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztZQUNmLE1BQU0sa0JBQWtCLENBQUMsV0FBVyxFQUFFLGlCQUFpQixFQUFFLEtBQUssQ0FBQztpQkFDNUQsS0FBSyxDQUFDLEdBQUcsRUFBRSxDQUFDLFNBQVMsQ0FBQyxDQUFDO1lBQzFCLE9BQU8sQ0FBQyxRQUFRLEdBQUcsQ0FBQyxDQUFDO1FBQ3ZCLENBQUM7SUFDSCxDQUFDLENBQUM7SUFDRixNQUFNLFFBQVEsR0FBRyxHQUFHLEVBQUU7UUFDcEIsS0FBSyxJQUFJLEVBQUUsQ0FBQztJQUNkLENBQUMsQ0FBQztJQUNGLE1BQU0sU0FBUyxHQUFHLEdBQUcsRUFBRTtRQUNyQixLQUFLLElBQUksRUFBRSxDQUFDO0lBQ2QsQ0FBQyxDQUFDO0lBQ0YsT0FBTyxDQUFDLEVBQUUsQ0FBQyxRQUFRLEVBQUUsUUFBUSxDQUFDLENBQUM7SUFDL0IsT0FBTyxDQUFDLEVBQUUsQ0FBQyxTQUFTLEVBQUUsU0FBUyxDQUFDLENBQUM7SUFDakMsSUFBSSxDQUFDO1FBQ0gsTUFBTSxRQUFRLENBQUMsS0FBSyxFQUFFLENBQUM7SUFDekIsQ0FBQztJQUFDLE9BQU8sS0FBSyxFQUFFLENBQUM7UUFDZixPQUFPLENBQUMsR0FBRyxDQUFDLFFBQVEsRUFBRSxRQUFRLENBQUMsQ0FBQztRQUNoQyxPQUFPLENBQUMsR0FBRyxDQUFDLFNBQVMsRUFBRSxTQUFTLENBQUMsQ0FBQztRQUNsQyxNQUFNLFFBQVEsQ0FBQyxJQUFJLEVBQUUsQ0FBQyxLQUFLLENBQUMsR0FBRyxFQUFFLENBQUMsU0FBUyxDQUFDLENBQUM7UUFDN0MsTUFBTSxLQUFLLENBQUM7SUFDZCxDQUFDO0FBQ0gsQ0FBQyxDQUFDIn0=
@@ -79,7 +79,8 @@ export interface ICoreMailReadiness {
79
79
  }
80
80
  export interface ICoreMailAuthLimiterSnapshot {
81
81
  inFlight: number;
82
- tokens: number;
82
+ waiting: number;
83
+ identities: number;
83
84
  }
84
85
  export interface ICoreMailPeer {
85
86
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/coremail",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Authenticated mail persistence and delivery orchestration for serve.zone workloads.",
5
5
  "main": "dist_ts/index.js",
6
6
  "typings": "dist_ts/index.d.ts",
@@ -8,24 +8,24 @@
8
8
  "author": "Task Venture Capital GmbH",
9
9
  "license": "MIT",
10
10
  "devDependencies": {
11
- "@git.zone/tsbuild": "^4.4.2",
12
- "@git.zone/tsdocker": "^3.1.1",
11
+ "@git.zone/tsbuild": "^4.4.3",
12
+ "@git.zone/tsdocker": "^3.5.8",
13
13
  "@git.zone/tsrun": "^2.0.6",
14
- "@git.zone/tstest": "^3.6.7",
14
+ "@git.zone/tstest": "6.0.0",
15
15
  "@git.zone/tswatch": "^3.3.5",
16
- "@push.rocks/smartmongo": "^7.0.0",
17
- "@types/node": "^26.1.1"
16
+ "@push.rocks/smartmongo": "9.0.0",
17
+ "@types/node": "26.5.0"
18
18
  },
19
19
  "dependencies": {
20
- "@api.global/typedrequest": "^3.7.0",
21
- "@api.global/typedrequest-interfaces": "^4.0.0",
22
- "@api.global/typedserver": "^8.10.0",
23
- "@api.global/typedsocket": "^5.1.1",
20
+ "@api.global/typedrequest": "^8.0.4",
21
+ "@api.global/typedrequest-interfaces": "^7.1.0",
22
+ "@api.global/typedserver": "^11.4.1",
23
+ "@api.global/typedsocket": "^8.2.1",
24
24
  "@push.rocks/projectinfo": "^5.1.0",
25
25
  "@push.rocks/smartbucket": "^4.11.0",
26
26
  "@push.rocks/smartdata": "^10.4.0",
27
27
  "@push.rocks/smartlog": "^3.2.2",
28
- "@serve.zone/interfaces": "^23.0.4",
28
+ "@serve.zone/interfaces": "30.12.0",
29
29
  "argon2": "^0.45.1"
30
30
  },
31
31
  "files": [
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/coremail',
6
- version: '1.0.2',
6
+ version: '1.2.0',
7
7
  description: 'Authenticated mail persistence and delivery orchestration for serve.zone workloads.'
8
8
  }
@@ -10,6 +10,7 @@ import {
10
10
  requireCoreMailIdentifier,
11
11
  requireCoreMailSafeInteger,
12
12
  } from './coremail.selectors.js';
13
+ import { logCoreMailFailure } from './coremail.log.js';
13
14
 
14
15
  type TCoreMailDesiredState = plugins.serveZoneInterfaces.data.ICoreMailDesiredState;
15
16
  type TCoreMailBinding = plugins.serveZoneInterfaces.data.ICoreMailBindingDesiredState;
@@ -26,6 +27,13 @@ export class CoreMailAuthenticationError extends Error {
26
27
  }
27
28
  }
28
29
 
30
+ export class CoreMailAuthLimitError extends CoreMailAuthenticationError {
31
+ public constructor() {
32
+ super();
33
+ this.name = 'CoreMailAuthLimitError';
34
+ }
35
+ }
36
+
29
37
  export class CoreMailAuthorizationError extends Error {
30
38
  public constructor() {
31
39
  super('CoreMail authority is unavailable.');
@@ -40,52 +48,154 @@ export class CoreMailCapabilityDeniedError extends Error {
40
48
  }
41
49
  }
42
50
 
51
+ export interface ICoreMailAuthLimiterOptions {
52
+ maximumInFlight?: number;
53
+ maximumWaiters?: number;
54
+ maximumWaitMs?: number;
55
+ identityCapacity?: number;
56
+ identityRefillPerSecond?: number;
57
+ maximumIdentities?: number;
58
+ now?: () => number;
59
+ }
60
+
61
+ interface IIdentityBucket {
62
+ tokens: number;
63
+ lastRefillAt: number;
64
+ }
65
+
66
+ interface ISlotWaiter {
67
+ grant: () => void;
68
+ expire: () => void;
69
+ timer: ReturnType<typeof setTimeout>;
70
+ }
71
+
43
72
  export class CoreMailAuthLimiter {
73
+ private readonly maximumInFlight: number;
74
+ private readonly maximumWaiters: number;
75
+ private readonly maximumWaitMs: number;
76
+ private readonly identityCapacity: number;
77
+ private readonly identityRefillPerSecond: number;
78
+ private readonly maximumIdentities: number;
79
+ private readonly now: () => number;
80
+
44
81
  private inFlight = 0;
45
- private tokens: number;
46
- private lastRefillAt: number;
82
+ private readonly waiters: ISlotWaiter[] = [];
83
+ private readonly identityBuckets = new Map<string, IIdentityBucket>();
47
84
 
48
- public constructor(
49
- private readonly maximumInFlight = 4,
50
- private readonly capacity = 8,
51
- private readonly refillPerSecond = 2,
52
- private readonly now: () => number = Date.now,
53
- ) {
54
- this.tokens = capacity;
55
- this.lastRefillAt = now();
85
+ public constructor(optionsArg: ICoreMailAuthLimiterOptions = {}) {
86
+ this.maximumInFlight = optionsArg.maximumInFlight ?? 4;
87
+ this.maximumWaiters = optionsArg.maximumWaiters ?? 64;
88
+ this.maximumWaitMs = optionsArg.maximumWaitMs ?? 2_500;
89
+ this.identityCapacity = optionsArg.identityCapacity ?? 4;
90
+ this.identityRefillPerSecond = optionsArg.identityRefillPerSecond ?? 1;
91
+ this.maximumIdentities = optionsArg.maximumIdentities ?? 4_096;
92
+ this.now = optionsArg.now ?? Date.now;
56
93
  }
57
94
 
58
- private refill(): void {
95
+ public snapshot(): ICoreMailAuthLimiterSnapshot {
96
+ return {
97
+ inFlight: this.inFlight,
98
+ waiting: this.waiters.length,
99
+ identities: this.identityBuckets.size,
100
+ };
101
+ }
102
+
103
+ public async run<T>(
104
+ identityArg: string,
105
+ workArg: () => Promise<T>,
106
+ ): Promise<T> {
107
+ this.consumeIdentityToken(identityArg);
108
+ await this.acquireSlot();
109
+ try {
110
+ return await workArg();
111
+ } finally {
112
+ this.releaseSlot();
113
+ }
114
+ }
115
+
116
+ private consumeIdentityToken(identityArg: string): void {
59
117
  const now = this.now();
60
- const elapsedMs = Math.max(0, now - this.lastRefillAt);
61
- if (elapsedMs < 1_000) {
118
+ const bucket = this.identityBuckets.get(identityArg);
119
+ if (!bucket) {
120
+ this.pruneIdentities(now);
121
+ this.identityBuckets.set(identityArg, {
122
+ tokens: this.identityCapacity - 1,
123
+ lastRefillAt: now,
124
+ });
62
125
  return;
63
126
  }
64
- const intervals = Math.floor(elapsedMs / 1_000);
65
- this.tokens = Math.min(
66
- this.capacity,
67
- this.tokens + (intervals * this.refillPerSecond),
68
- );
69
- this.lastRefillAt += intervals * 1_000;
127
+ const intervals = Math.floor(Math.max(0, now - bucket.lastRefillAt) / 1_000);
128
+ if (intervals > 0) {
129
+ bucket.tokens = Math.min(
130
+ this.identityCapacity,
131
+ bucket.tokens + (intervals * this.identityRefillPerSecond),
132
+ );
133
+ bucket.lastRefillAt += intervals * 1_000;
134
+ }
135
+ if (bucket.tokens < 1) {
136
+ throw new CoreMailAuthLimitError();
137
+ }
138
+ bucket.tokens--;
70
139
  }
71
140
 
72
- public snapshot(): ICoreMailAuthLimiterSnapshot {
73
- this.refill();
74
- return { inFlight: this.inFlight, tokens: this.tokens };
141
+ private pruneIdentities(nowArg: number): void {
142
+ if (this.identityBuckets.size < this.maximumIdentities) {
143
+ return;
144
+ }
145
+ for (const [identity, bucket] of this.identityBuckets) {
146
+ const refilled = bucket.tokens + (
147
+ Math.floor(Math.max(0, nowArg - bucket.lastRefillAt) / 1_000)
148
+ * this.identityRefillPerSecond
149
+ );
150
+ if (refilled >= this.identityCapacity) {
151
+ this.identityBuckets.delete(identity);
152
+ if (this.identityBuckets.size < this.maximumIdentities) {
153
+ return;
154
+ }
155
+ }
156
+ }
157
+ for (const identity of this.identityBuckets.keys()) {
158
+ this.identityBuckets.delete(identity);
159
+ if (this.identityBuckets.size < this.maximumIdentities) {
160
+ return;
161
+ }
162
+ }
75
163
  }
76
164
 
77
- public async run<T>(workArg: () => Promise<T>): Promise<T> {
78
- this.refill();
79
- if (this.inFlight >= this.maximumInFlight || this.tokens < 1) {
80
- throw new CoreMailAuthenticationError();
165
+ private async acquireSlot(): Promise<void> {
166
+ if (this.inFlight < this.maximumInFlight) {
167
+ this.inFlight++;
168
+ return;
81
169
  }
82
- this.tokens--;
83
- this.inFlight++;
84
- try {
85
- return await workArg();
86
- } finally {
87
- this.inFlight--;
170
+ if (this.waiters.length >= this.maximumWaiters) {
171
+ throw new CoreMailAuthLimitError();
88
172
  }
173
+ await new Promise<void>((resolveArg, rejectArg) => {
174
+ const waiter: ISlotWaiter = {
175
+ grant: () => {
176
+ clearTimeout(waiter.timer);
177
+ resolveArg();
178
+ },
179
+ expire: () => {
180
+ const index = this.waiters.indexOf(waiter);
181
+ if (index >= 0) {
182
+ this.waiters.splice(index, 1);
183
+ }
184
+ rejectArg(new CoreMailAuthLimitError());
185
+ },
186
+ timer: setTimeout(() => waiter.expire(), this.maximumWaitMs),
187
+ };
188
+ this.waiters.push(waiter);
189
+ });
190
+ }
191
+
192
+ private releaseSlot(): void {
193
+ const waiter = this.waiters.shift();
194
+ if (waiter) {
195
+ waiter.grant();
196
+ return;
197
+ }
198
+ this.inFlight--;
89
199
  }
90
200
  }
91
201
 
@@ -108,7 +218,7 @@ const findAcceptedVerifier = (
108
218
  );
109
219
 
110
220
  export class CoreMailAuth {
111
- public readonly limiter = new CoreMailAuthLimiter();
221
+ public readonly limiter: CoreMailAuthLimiter;
112
222
 
113
223
  public constructor(
114
224
  private readonly controlBootstrap:
@@ -116,7 +226,9 @@ export class CoreMailAuth {
116
226
  private readonly getActiveDesiredState: () =>
117
227
  Promise<TCoreMailDesiredState | null>,
118
228
  private readonly now: () => number = Date.now,
119
- ) {}
229
+ ) {
230
+ this.limiter = new CoreMailAuthLimiter({ now: this.now });
231
+ }
120
232
 
121
233
  private requireFreshPeer(peerArg: ICoreMailPeer): void {
122
234
  const deadline = peerArg.context.state[coreMailPreAuthDeadlineKey];
@@ -130,19 +242,23 @@ export class CoreMailAuth {
130
242
  }
131
243
  }
132
244
 
133
- private async verify(
134
- verifierArg: TCoreMailVerifier | undefined,
135
- secretArg: string,
136
- ): Promise<void> {
245
+ private requireSecretShape(secretArg: unknown): string {
137
246
  if (
138
- !verifierArg
139
- || typeof secretArg !== 'string'
247
+ typeof secretArg !== 'string'
140
248
  || secretArg.length < 32
141
249
  || secretArg.length > 512
142
250
  ) {
143
251
  throw new CoreMailAuthenticationError();
144
252
  }
145
- const verified = await this.limiter.run(async () =>
253
+ return secretArg;
254
+ }
255
+
256
+ private async verify(
257
+ verifierArg: TCoreMailVerifier,
258
+ secretArg: string,
259
+ identityArg: string,
260
+ ): Promise<void> {
261
+ const verified = await this.limiter.run(identityArg, async () =>
146
262
  plugins.argon2.verify(verifierArg.verificationHash, secretArg)
147
263
  );
148
264
  if (!verified) {
@@ -156,8 +272,10 @@ export class CoreMailAuth {
156
272
  plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailAuthenticateControl['request'],
157
273
  ): Promise<ICoreMailControlSession> {
158
274
  this.requireFreshPeer(peerArg);
275
+ let failureCode = 'INVALID_INPUT';
276
+ let credentialId: string | undefined;
159
277
  try {
160
- const credentialId = requireCoreMailIdentifier(
278
+ credentialId = requireCoreMailIdentifier(
161
279
  requestArg.credentialId,
162
280
  'credentialId',
163
281
  );
@@ -166,13 +284,19 @@ export class CoreMailAuth {
166
284
  'credentialVersion',
167
285
  1,
168
286
  );
287
+ const credentialSecret = this.requireSecretShape(requestArg.credentialSecret);
288
+ failureCode = 'UNKNOWN_CREDENTIAL';
169
289
  const verifier = findAcceptedVerifier(
170
290
  this.controlBootstrap.credentials,
171
291
  credentialId,
172
292
  credentialVersion,
173
293
  this.now(),
174
294
  );
175
- await this.verify(verifier, requestArg.credentialSecret);
295
+ if (!verifier) {
296
+ throw new CoreMailAuthenticationError();
297
+ }
298
+ failureCode = 'VERIFIER_REJECTED';
299
+ await this.verify(verifier, credentialSecret, `control:${credentialId}`);
176
300
  const session = Object.freeze<ICoreMailControlSession>({
177
301
  kind: 'control',
178
302
  peerId: peerArg.id,
@@ -182,8 +306,17 @@ export class CoreMailAuth {
182
306
  });
183
307
  peerArg.data.set(coreMailSessionKey, session);
184
308
  return session;
185
- } catch {
309
+ } catch (error) {
186
310
  peerArg.close(4401, 'authentication failed');
311
+ await logCoreMailFailure(
312
+ 'auth',
313
+ error instanceof CoreMailAuthLimitError ? 'LIMITER_EXHAUSTED' : failureCode,
314
+ undefined,
315
+ {
316
+ kind: 'control',
317
+ ...(credentialId === undefined ? {} : { credentialId }),
318
+ },
319
+ ).catch(() => undefined);
187
320
  throw new CoreMailAuthenticationError();
188
321
  }
189
322
  }
@@ -194,13 +327,16 @@ export class CoreMailAuth {
194
327
  plugins.serveZoneInterfaces.requests.coremail.IReq_CoreMailAuthenticateWorkload['request'],
195
328
  ): Promise<ICoreMailWorkloadSession> {
196
329
  this.requireFreshPeer(peerArg);
330
+ let failureCode = 'INVALID_INPUT';
331
+ let bindingId: string | undefined;
332
+ let credentialId: string | undefined;
197
333
  try {
198
334
  const desiredState = await this.getActiveDesiredState();
199
- const bindingId = requireCoreMailIdentifier(
335
+ bindingId = requireCoreMailIdentifier(
200
336
  requestArg.bindingId,
201
337
  'bindingId',
202
338
  );
203
- const credentialId = requireCoreMailIdentifier(
339
+ credentialId = requireCoreMailIdentifier(
204
340
  requestArg.credentialId,
205
341
  'credentialId',
206
342
  );
@@ -209,44 +345,64 @@ export class CoreMailAuth {
209
345
  'credentialVersion',
210
346
  1,
211
347
  );
348
+ const credentialSecret = this.requireSecretShape(requestArg.credentialSecret);
349
+ failureCode = 'UNKNOWN_BINDING';
212
350
  const binding = desiredState?.bindings.find((bindingArg) =>
213
351
  bindingArg.bindingId === bindingId
214
352
  && bindingArg.state !== 'disabled'
215
353
  );
216
- const verifier = binding && findAcceptedVerifier(
354
+ if (!desiredState || !binding || binding.state === 'disabled') {
355
+ throw new CoreMailAuthenticationError();
356
+ }
357
+ failureCode = 'UNKNOWN_CREDENTIAL';
358
+ const verifier = findAcceptedVerifier(
217
359
  binding.credentials,
218
360
  credentialId,
219
361
  credentialVersion,
220
362
  this.now(),
221
363
  );
222
- await this.verify(verifier, requestArg.credentialSecret);
364
+ if (!verifier) {
365
+ throw new CoreMailAuthenticationError();
366
+ }
367
+ failureCode = 'VERIFIER_REJECTED';
368
+ await this.verify(verifier, credentialSecret, `workload:${bindingId}`);
223
369
  const allowedOperations =
224
370
  plugins.serveZoneInterfaces.data.resolveCoreMailWorkloadOperations(
225
- binding!.state,
226
- binding!.capabilities,
371
+ binding.state,
372
+ binding.capabilities,
227
373
  );
228
374
  const session = Object.freeze<ICoreMailWorkloadSession>({
229
375
  kind: 'workload',
230
376
  peerId: peerArg.id,
231
- tenantId: binding!.tenantId,
232
- serviceId: binding!.serviceId,
233
- bindingId: binding!.bindingId,
234
- bindingRevision: binding!.revision,
235
- configEpoch: desiredState!.configEpoch,
377
+ tenantId: binding.tenantId,
378
+ serviceId: binding.serviceId,
379
+ bindingId: binding.bindingId,
380
+ bindingRevision: binding.revision,
381
+ configEpoch: desiredState.configEpoch,
236
382
  credentialId,
237
383
  credentialVersion,
238
- bindingState: binding!.state as 'active' | 'draining',
239
- capabilities: Object.freeze([...binding!.capabilities]) as
384
+ bindingState: binding.state,
385
+ capabilities: Object.freeze([...binding.capabilities]) as
240
386
  plugins.serveZoneInterfaces.data.TCoreMailCapability[],
241
387
  allowedOperations: Object.freeze([...allowedOperations]) as
242
388
  plugins.serveZoneInterfaces.data.TCoreMailWorkloadOperation[],
243
- coreMailTransferOrigin: desiredState!.gateway.coreMailTransferOrigin,
389
+ coreMailTransferOrigin: desiredState.gateway.coreMailTransferOrigin,
244
390
  authenticatedAt: this.now(),
245
391
  });
246
392
  peerArg.data.set(coreMailSessionKey, session);
247
393
  return session;
248
- } catch {
394
+ } catch (error) {
249
395
  peerArg.close(4401, 'authentication failed');
396
+ await logCoreMailFailure(
397
+ 'auth',
398
+ error instanceof CoreMailAuthLimitError ? 'LIMITER_EXHAUSTED' : failureCode,
399
+ undefined,
400
+ {
401
+ kind: 'workload',
402
+ ...(bindingId === undefined ? {} : { bindingId }),
403
+ ...(credentialId === undefined ? {} : { credentialId }),
404
+ },
405
+ ).catch(() => undefined);
250
406
  throw new CoreMailAuthenticationError();
251
407
  }
252
408
  }
@@ -3,8 +3,13 @@ import type { ICoreMailConfig } from './interfaces.js';
3
3
 
4
4
  const requireEnv = (keyArg: string): string => {
5
5
  const value = process.env[keyArg];
6
- if (!value || value.trim() !== value) {
7
- throw new Error(`CoreMail requires a non-empty canonical ${keyArg}.`);
6
+ if (!value) {
7
+ throw new Error(`CoreMail requires ${keyArg} to be set and non-empty.`);
8
+ }
9
+ if (value.trim() !== value) {
10
+ throw new Error(
11
+ `CoreMail requires ${keyArg} to be canonical without surrounding whitespace.`,
12
+ );
8
13
  }
9
14
  return value;
10
15
  };
@@ -145,8 +145,8 @@ export class CoreMailDesiredStateService {
145
145
  modelArg.state = 'active';
146
146
  modelArg.activatedAt = stateArg.updatedAt;
147
147
  },
148
- }).catch(async () => {
149
- await logCoreMailFailure('desired-state', 'SNAPSHOT_REPAIR_FAILED')
148
+ }).catch(async (error) => {
149
+ await logCoreMailFailure('desired-state', 'SNAPSHOT_REPAIR_FAILED', error)
150
150
  .catch(() => undefined);
151
151
  });
152
152
  }
@@ -27,6 +27,21 @@ const gatewayAuthority = (serviceIdArg: string): ITransferAuthority => ({
27
27
  bindingId: '-',
28
28
  });
29
29
 
30
+ export const stableStringify = (valueArg: unknown): string => {
31
+ if (Array.isArray(valueArg)) {
32
+ return `[${valueArg.map(stableStringify).join(',')}]`;
33
+ }
34
+ if (valueArg && typeof valueArg === 'object') {
35
+ const record = valueArg as Record<string, unknown>;
36
+ return `{${Object.keys(record)
37
+ .filter((keyArg) => record[keyArg] !== undefined)
38
+ .sort()
39
+ .map((keyArg) => `${JSON.stringify(keyArg)}:${stableStringify(record[keyArg])}`)
40
+ .join(',')}}`;
41
+ }
42
+ return JSON.stringify(valueArg) ?? 'null';
43
+ };
44
+
30
45
  const normalizeTransferOrigin = (valueArg: unknown): string => {
31
46
  if (typeof valueArg !== 'string') {
32
47
  throw new Error('CoreMail gateway returned an invalid transfer origin.');
@@ -138,7 +153,7 @@ export class CoreMailGateway {
138
153
  if (!gateway) {
139
154
  await this.disconnect();
140
155
  } else {
141
- const signature = JSON.stringify(gateway);
156
+ const signature = stableStringify(gateway);
142
157
  if (
143
158
  !this.socket
144
159
  || signature !== this.socketSignature
@@ -152,8 +167,8 @@ export class CoreMailGateway {
152
167
  this.queueAuthentication(gateway, signature);
153
168
  }
154
169
  }
155
- } catch {
156
- await logCoreMailFailure('gateway', 'RECONCILIATION_FAILED')
170
+ } catch (error) {
171
+ await logCoreMailFailure('gateway', 'RECONCILIATION_FAILED', error)
157
172
  .catch(() => undefined);
158
173
  await this.disconnect();
159
174
  }
@@ -264,12 +279,12 @@ export class CoreMailGateway {
264
279
  this.transferOrigin = transferOrigin;
265
280
  this.authenticatedSignature = signatureArg;
266
281
  })
267
- .catch(async () => {
282
+ .catch(async (error) => {
268
283
  if (this.socketSignature === signatureArg) {
269
284
  this.authenticatedSignature = '';
270
285
  this.transferOrigin = null;
271
286
  }
272
- await logCoreMailFailure('gateway', 'AUTHENTICATION_FAILED')
287
+ await logCoreMailFailure('gateway', 'AUTHENTICATION_FAILED', error)
273
288
  .catch(() => undefined);
274
289
  })
275
290
  .finally(() => {
@@ -596,8 +611,8 @@ export class CoreMailOutboundWorker {
596
611
  private async run(): Promise<void> {
597
612
  while (!this.stopController.signal.aborted) {
598
613
  if (this.gateway.isReady()) {
599
- await this.processBatch().catch(async () => {
600
- await logCoreMailFailure('worker', 'BATCH_FAILED')
614
+ await this.processBatch().catch(async (error) => {
615
+ await logCoreMailFailure('worker', 'BATCH_FAILED', error)
601
616
  .catch(() => undefined);
602
617
  });
603
618
  }
@@ -742,9 +757,10 @@ export class CoreMailOutboundWorker {
742
757
  response.status,
743
758
  leaseTokenArg,
744
759
  );
745
- } catch {
746
- await logCoreMailFailure('worker', 'DELIVERY_ATTEMPT_FAILED')
747
- .catch(() => undefined);
760
+ } catch (error) {
761
+ await logCoreMailFailure('worker', 'DELIVERY_ATTEMPT_FAILED', error, {
762
+ submissionId: submissionArg.submissionId,
763
+ }).catch(() => undefined);
748
764
  const delayMs = Math.min(
749
765
  15 * 60 * 1000,
750
766
  1_000 * (2 ** Math.min(submissionArg.attempts, 9)),
@@ -315,9 +315,10 @@ export class CoreMailMaintenanceService {
315
315
  if (this.sweepTask) return await this.sweepTask;
316
316
  const controller = new AbortController();
317
317
  this.sweepController = controller;
318
- const task = this.sweep(controller.signal).catch(async () => {
318
+ const task = this.sweep(controller.signal).catch(async (error) => {
319
319
  if (!controller.signal.aborted) {
320
- await logCoreMailFailure('retention', 'SWEEP_FAILED').catch(() => undefined);
320
+ await logCoreMailFailure('retention', 'SWEEP_FAILED', error)
321
+ .catch(() => undefined);
321
322
  }
322
323
  });
323
324
  this.sweepTask = task;
@@ -20,6 +20,7 @@ import type {
20
20
  ICoreMailWorkloadSession,
21
21
  } from './interfaces.js';
22
22
  import { CoreMailQuotaExceededError } from './coremail.quota.js';
23
+ import { logCoreMailFailure } from './coremail.log.js';
23
24
 
24
25
  type TErrorCode = plugins.serveZoneInterfaces.data.TCoreMailErrorCode;
25
26
 
@@ -425,14 +426,21 @@ export class CoreMailServer {
425
426
  });
426
427
  }
427
428
 
429
+ /**
430
+ * Register one typed handler with uniform failure redaction.
431
+ *
432
+ * `handlerArg` is typed as typedrequest's own `THandlerFunction` rather than
433
+ * a hand-written signature: since typedrequest 8 both sides are wrapped in
434
+ * the `TReverseVirtualStreamDirections` mapped type, which does not reduce
435
+ * inside a generic wrapper, so any restatement of the shape stops being
436
+ * assignable. Concrete `IReq_*` types reduce normally, which is why the call
437
+ * sites below need no change.
438
+ */
428
439
  private addHandler<
429
440
  TRequest extends plugins.typedrequestInterfaces.ITypedRequest,
430
441
  >(
431
442
  methodArg: TRequest['method'],
432
- handlerArg: (
433
- requestArg: TRequest['request'],
434
- toolsArg: ICoreMailTypedTools | undefined,
435
- ) => Promise<TRequest['response']>,
443
+ handlerArg: plugins.typedrequest.THandlerFunction<TRequest>,
436
444
  ): void {
437
445
  this.router.addTypedHandler(new plugins.typedrequest.TypedHandler<TRequest>(
438
446
  methodArg,
@@ -440,7 +448,13 @@ export class CoreMailServer {
440
448
  try {
441
449
  return await handlerArg(requestArg, toolsArg);
442
450
  } catch (error) {
443
- throw privacySafeError(error);
451
+ const safeError = privacySafeError(error);
452
+ if (safeError.errorData?.code === 'INVALID_REQUEST') {
453
+ await logCoreMailFailure('server', 'UNEXPECTED_HANDLER_FAILURE', error, {
454
+ method: methodArg,
455
+ }).catch(() => undefined);
456
+ }
457
+ throw safeError;
444
458
  }
445
459
  },
446
460
  ));