@serve.zone/coremail 1.0.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/.smartconfig.json +50 -0
- package/changelog.md +10 -0
- package/cli.js +4 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.auth.d.ts +45 -0
- package/dist_ts/classes.auth.js +204 -0
- package/dist_ts/classes.config.d.ts +2 -0
- package/dist_ts/classes.config.js +72 -0
- package/dist_ts/classes.coremail.d.ts +41 -0
- package/dist_ts/classes.coremail.js +155 -0
- package/dist_ts/classes.desiredstate.d.ts +40 -0
- package/dist_ts/classes.desiredstate.js +382 -0
- package/dist_ts/classes.gateway.d.ts +62 -0
- package/dist_ts/classes.gateway.js +669 -0
- package/dist_ts/classes.inbound.d.ts +49 -0
- package/dist_ts/classes.inbound.js +702 -0
- package/dist_ts/classes.maintenance.d.ts +16 -0
- package/dist_ts/classes.maintenance.js +339 -0
- package/dist_ts/classes.models.d.ts +161 -0
- package/dist_ts/classes.models.js +595 -0
- package/dist_ts/classes.server.d.ts +40 -0
- package/dist_ts/classes.server.js +309 -0
- package/dist_ts/classes.storage.d.ts +32 -0
- package/dist_ts/classes.storage.js +317 -0
- package/dist_ts/classes.submissions.d.ts +33 -0
- package/dist_ts/classes.submissions.js +385 -0
- package/dist_ts/classes.transfer.d.ts +41 -0
- package/dist_ts/classes.transfer.js +289 -0
- package/dist_ts/coremail.cursor.d.ts +20 -0
- package/dist_ts/coremail.cursor.js +191 -0
- package/dist_ts/coremail.log.d.ts +3 -0
- package/dist_ts/coremail.log.js +11 -0
- package/dist_ts/coremail.mime.d.ts +8 -0
- package/dist_ts/coremail.mime.js +63 -0
- package/dist_ts/coremail.persistence.d.ts +148 -0
- package/dist_ts/coremail.persistence.js +695 -0
- package/dist_ts/coremail.quota.d.ts +29 -0
- package/dist_ts/coremail.quota.js +149 -0
- package/dist_ts/coremail.selectors.d.ts +3 -0
- package/dist_ts/coremail.selectors.js +21 -0
- package/dist_ts/coremail.validation.d.ts +9 -0
- package/dist_ts/coremail.validation.js +325 -0
- package/dist_ts/index.d.ts +4 -0
- package/dist_ts/index.js +39 -0
- package/dist_ts/interfaces.d.ts +92 -0
- package/dist_ts/interfaces.js +2 -0
- package/dist_ts/plugins.d.ts +11 -0
- package/dist_ts/plugins.js +12 -0
- package/license.md +19 -0
- package/package.json +48 -0
- package/readme.md +199 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.auth.ts +321 -0
- package/ts/classes.config.ts +104 -0
- package/ts/classes.coremail.ts +249 -0
- package/ts/classes.desiredstate.ts +508 -0
- package/ts/classes.gateway.ts +811 -0
- package/ts/classes.inbound.ts +898 -0
- package/ts/classes.maintenance.ts +335 -0
- package/ts/classes.models.ts +530 -0
- package/ts/classes.server.ts +444 -0
- package/ts/classes.storage.ts +448 -0
- package/ts/classes.submissions.ts +554 -0
- package/ts/classes.transfer.ts +379 -0
- package/ts/coremail.cursor.ts +328 -0
- package/ts/coremail.log.ts +21 -0
- package/ts/coremail.mime.ts +94 -0
- package/ts/coremail.persistence.ts +1156 -0
- package/ts/coremail.quota.ts +214 -0
- package/ts/coremail.selectors.ts +35 -0
- package/ts/coremail.validation.ts +446 -0
- package/ts/index.ts +38 -0
- package/ts/interfaces.ts +109 -0
- package/ts/plugins.ts +11 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
type ICoreMailModels,
|
|
4
|
+
} from './classes.models.js';
|
|
5
|
+
import {
|
|
6
|
+
CoreMailStorage,
|
|
7
|
+
createOpaqueToken,
|
|
8
|
+
createSha256,
|
|
9
|
+
} from './classes.storage.js';
|
|
10
|
+
import type {
|
|
11
|
+
ITransferAuthority,
|
|
12
|
+
TTransferGrantPurpose,
|
|
13
|
+
} from './interfaces.js';
|
|
14
|
+
import { logCoreMailFailure } from './coremail.log.js';
|
|
15
|
+
import type { ICoreMailTransferGrantRecord } from './coremail.persistence.js';
|
|
16
|
+
import {
|
|
17
|
+
requireCoreMailIdentifier,
|
|
18
|
+
requireCoreMailUuid,
|
|
19
|
+
} from './coremail.selectors.js';
|
|
20
|
+
|
|
21
|
+
type TCoreMailSha256 = plugins.serveZoneInterfaces.data.TCoreMailSha256;
|
|
22
|
+
type TStoredTransferGrant =
|
|
23
|
+
plugins.smartdata.TStoredDocument<ICoreMailTransferGrantRecord>;
|
|
24
|
+
|
|
25
|
+
const transferPathRegex = /^\/transfers\/([a-f0-9-]{36})$/;
|
|
26
|
+
|
|
27
|
+
const maximumBytesForPurpose = (purposeArg: TTransferGrantPurpose): number => {
|
|
28
|
+
const limits = plugins.serveZoneInterfaces.data.coreMailLimits;
|
|
29
|
+
switch (purposeArg) {
|
|
30
|
+
case 'desired-state-upload':
|
|
31
|
+
return limits.desiredStateBytes;
|
|
32
|
+
case 'outbound-part-upload':
|
|
33
|
+
return limits.attachmentBytes;
|
|
34
|
+
case 'outbound-mime-download':
|
|
35
|
+
case 'inbound-mime-upload':
|
|
36
|
+
case 'inbound-mime-download':
|
|
37
|
+
return limits.serializedMimeBytes;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const secureDigestEqual = (
|
|
42
|
+
leftArg: TCoreMailSha256,
|
|
43
|
+
rightArg: TCoreMailSha256,
|
|
44
|
+
): boolean => {
|
|
45
|
+
const left = Buffer.from(leftArg.slice('sha256:'.length), 'hex');
|
|
46
|
+
const right = Buffer.from(rightArg.slice('sha256:'.length), 'hex');
|
|
47
|
+
return left.byteLength === right.byteLength
|
|
48
|
+
&& plugins.nodeCrypto.timingSafeEqual(left, right);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const authorityFilter = (
|
|
52
|
+
grantIdArg: string,
|
|
53
|
+
authorityArg: ITransferAuthority,
|
|
54
|
+
) => ({
|
|
55
|
+
grantId: requireCoreMailUuid(grantIdArg, 'grantId'),
|
|
56
|
+
authorityKind: authorityArg.kind,
|
|
57
|
+
tenantId: authorityArg.tenantId === '-'
|
|
58
|
+
? '-'
|
|
59
|
+
: requireCoreMailIdentifier(authorityArg.tenantId, 'authority.tenantId'),
|
|
60
|
+
serviceId: requireCoreMailIdentifier(
|
|
61
|
+
authorityArg.serviceId,
|
|
62
|
+
'authority.serviceId',
|
|
63
|
+
),
|
|
64
|
+
bindingId: authorityArg.bindingId === '-'
|
|
65
|
+
? '-'
|
|
66
|
+
: requireCoreMailIdentifier(authorityArg.bindingId, 'authority.bindingId'),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export class CoreMailTransferService {
|
|
70
|
+
private readonly completionTasks = new Set<Promise<unknown>>();
|
|
71
|
+
private readonly operationControllers = new Set<AbortController>();
|
|
72
|
+
private readonly stopController = new AbortController();
|
|
73
|
+
private stopping = false;
|
|
74
|
+
|
|
75
|
+
public constructor(
|
|
76
|
+
private readonly models: ICoreMailModels,
|
|
77
|
+
private readonly storage: CoreMailStorage,
|
|
78
|
+
private readonly now: () => number = Date.now,
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
public async stop(): Promise<void> {
|
|
82
|
+
this.stopping = true;
|
|
83
|
+
this.stopController.abort(new Error('CoreMail transfer service is stopping.'));
|
|
84
|
+
for (const controller of this.operationControllers) {
|
|
85
|
+
controller.abort(new Error('CoreMail transfer service is stopping.'));
|
|
86
|
+
}
|
|
87
|
+
await Promise.allSettled([...this.completionTasks]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private track<T>(taskArg: Promise<T>, controllerArg: AbortController): void {
|
|
91
|
+
this.completionTasks.add(taskArg);
|
|
92
|
+
this.operationControllers.add(controllerArg);
|
|
93
|
+
void taskArg.then(
|
|
94
|
+
() => {
|
|
95
|
+
this.completionTasks.delete(taskArg);
|
|
96
|
+
this.operationControllers.delete(controllerArg);
|
|
97
|
+
},
|
|
98
|
+
() => {
|
|
99
|
+
this.completionTasks.delete(taskArg);
|
|
100
|
+
this.operationControllers.delete(controllerArg);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public async issueGrant(optionsArg: {
|
|
106
|
+
authority: ITransferAuthority;
|
|
107
|
+
purpose: TTransferGrantPurpose;
|
|
108
|
+
method: 'PUT' | 'GET';
|
|
109
|
+
expectedSha256: TCoreMailSha256;
|
|
110
|
+
lengthBytes: number;
|
|
111
|
+
contentType: string;
|
|
112
|
+
objectKey: string;
|
|
113
|
+
}): Promise<plugins.serveZoneInterfaces.data.ICoreMailTransferGrant> {
|
|
114
|
+
if (this.stopping) {
|
|
115
|
+
throw new Error('CoreMail transfer service is stopping.');
|
|
116
|
+
}
|
|
117
|
+
const maximumBytes = maximumBytesForPurpose(optionsArg.purpose);
|
|
118
|
+
if (
|
|
119
|
+
!Number.isSafeInteger(optionsArg.lengthBytes)
|
|
120
|
+
|| optionsArg.lengthBytes < 0
|
|
121
|
+
|| optionsArg.lengthBytes > maximumBytes
|
|
122
|
+
) {
|
|
123
|
+
throw new Error('CoreMail transfer length exceeds its purpose budget.');
|
|
124
|
+
}
|
|
125
|
+
const grantId = plugins.nodeCrypto.randomUUID();
|
|
126
|
+
const bearerToken = createOpaqueToken();
|
|
127
|
+
const path = `/transfers/${grantId}`;
|
|
128
|
+
const createdAt = this.now();
|
|
129
|
+
const expiresAt =
|
|
130
|
+
createdAt + plugins.serveZoneInterfaces.data.coreMailLimits.transferGrantTtlMs;
|
|
131
|
+
const inserted = await this.models.TransferGrant.exact.insert({
|
|
132
|
+
grantId,
|
|
133
|
+
authorityKind: optionsArg.authority.kind,
|
|
134
|
+
tenantId: optionsArg.authority.tenantId,
|
|
135
|
+
serviceId: optionsArg.authority.serviceId,
|
|
136
|
+
bindingId: optionsArg.authority.bindingId,
|
|
137
|
+
purpose: optionsArg.purpose,
|
|
138
|
+
method: optionsArg.method,
|
|
139
|
+
path,
|
|
140
|
+
bearerSha256: createSha256(bearerToken),
|
|
141
|
+
expectedSha256: optionsArg.expectedSha256,
|
|
142
|
+
lengthBytes: optionsArg.lengthBytes,
|
|
143
|
+
contentType: optionsArg.contentType,
|
|
144
|
+
objectKey: optionsArg.objectKey,
|
|
145
|
+
state: 'issued',
|
|
146
|
+
expiresAt,
|
|
147
|
+
createdAt,
|
|
148
|
+
});
|
|
149
|
+
if (inserted.status === 'conflict') {
|
|
150
|
+
throw new Error('CoreMail transfer grant identity conflict.');
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
grantId,
|
|
154
|
+
method: optionsArg.method,
|
|
155
|
+
path,
|
|
156
|
+
bearerToken,
|
|
157
|
+
sha256: optionsArg.expectedSha256,
|
|
158
|
+
lengthBytes: optionsArg.lengthBytes,
|
|
159
|
+
contentType: optionsArg.contentType,
|
|
160
|
+
issuedAt: createdAt,
|
|
161
|
+
expiresAt,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public async assertConsumed(optionsArg: {
|
|
166
|
+
grantId: string;
|
|
167
|
+
authority: ITransferAuthority;
|
|
168
|
+
purpose: TTransferGrantPurpose;
|
|
169
|
+
expectedSha256: TCoreMailSha256;
|
|
170
|
+
lengthBytes: number;
|
|
171
|
+
objectKey?: string;
|
|
172
|
+
}): Promise<TStoredTransferGrant> {
|
|
173
|
+
const grant = await this.models.TransferGrant.exact.findStoredOne({
|
|
174
|
+
...authorityFilter(optionsArg.grantId, optionsArg.authority),
|
|
175
|
+
state: 'consumed',
|
|
176
|
+
purpose: optionsArg.purpose,
|
|
177
|
+
expectedSha256: optionsArg.expectedSha256,
|
|
178
|
+
lengthBytes: optionsArg.lengthBytes,
|
|
179
|
+
...(optionsArg.objectKey === undefined
|
|
180
|
+
? {}
|
|
181
|
+
: { objectKey: optionsArg.objectKey }),
|
|
182
|
+
});
|
|
183
|
+
if (!grant) {
|
|
184
|
+
throw new Error('CoreMail transfer grant is not consumed by this authority.');
|
|
185
|
+
}
|
|
186
|
+
await this.storage.verifyObject(
|
|
187
|
+
grant.objectKey,
|
|
188
|
+
grant.lengthBytes,
|
|
189
|
+
grant.expectedSha256,
|
|
190
|
+
maximumBytesForPurpose(grant.purpose),
|
|
191
|
+
);
|
|
192
|
+
return grant;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public async handleHttp(
|
|
196
|
+
contextArg: plugins.typedserver.IRequestContext,
|
|
197
|
+
): Promise<Response | null> {
|
|
198
|
+
const match = transferPathRegex.exec(contextArg.path);
|
|
199
|
+
if (!match) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
if (contextArg.method !== 'GET' && contextArg.method !== 'PUT') {
|
|
203
|
+
return new Response('Method Not Allowed', {
|
|
204
|
+
status: 405,
|
|
205
|
+
headers: { Allow: 'GET, PUT' },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const authorization = contextArg.headers.get('authorization');
|
|
209
|
+
if (!authorization?.startsWith('Bearer ')) {
|
|
210
|
+
return new Response('Unauthorized', { status: 401 });
|
|
211
|
+
}
|
|
212
|
+
const bearer = authorization.slice('Bearer '.length);
|
|
213
|
+
if (bearer.length < 32 || bearer.length > 512) {
|
|
214
|
+
return new Response('Unauthorized', { status: 401 });
|
|
215
|
+
}
|
|
216
|
+
const grantId = match[1];
|
|
217
|
+
const grant = await this.models.TransferGrant.exact.findStoredOne({
|
|
218
|
+
grantId,
|
|
219
|
+
method: contextArg.method,
|
|
220
|
+
state: 'issued',
|
|
221
|
+
});
|
|
222
|
+
if (
|
|
223
|
+
!grant
|
|
224
|
+
|| grant.expiresAt < this.now()
|
|
225
|
+
|| !secureDigestEqual(grant.bearerSha256, createSha256(bearer))
|
|
226
|
+
) {
|
|
227
|
+
return new Response('Unauthorized', { status: 401 });
|
|
228
|
+
}
|
|
229
|
+
const claim = await this.models.TransferGrant.exact.transition({
|
|
230
|
+
current: grant,
|
|
231
|
+
change: (modelArg) => {
|
|
232
|
+
modelArg.state = 'transferring';
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
if (claim.status !== 'transitioned') {
|
|
236
|
+
return new Response('Conflict', { status: 409 });
|
|
237
|
+
}
|
|
238
|
+
if (contextArg.method === 'PUT') {
|
|
239
|
+
return await this.handleUpload(contextArg, claim.document);
|
|
240
|
+
}
|
|
241
|
+
return await this.handleDownload(contextArg, claim.document);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async handleUpload(
|
|
245
|
+
contextArg: plugins.typedserver.IRequestContext,
|
|
246
|
+
grantArg: TStoredTransferGrant,
|
|
247
|
+
): Promise<Response> {
|
|
248
|
+
const contentLength = Number(contextArg.headers.get('content-length'));
|
|
249
|
+
if (
|
|
250
|
+
!contextArg.request.body
|
|
251
|
+
|| !Number.isSafeInteger(contentLength)
|
|
252
|
+
|| contentLength !== grantArg.lengthBytes
|
|
253
|
+
|| contextArg.headers.get('content-type') !== grantArg.contentType
|
|
254
|
+
) {
|
|
255
|
+
await this.failGrant(grantArg.grantId);
|
|
256
|
+
return new Response('Invalid transfer metadata', { status: 400 });
|
|
257
|
+
}
|
|
258
|
+
const operationController = new AbortController();
|
|
259
|
+
const signal = AbortSignal.any([
|
|
260
|
+
contextArg.request.signal,
|
|
261
|
+
this.stopController.signal,
|
|
262
|
+
operationController.signal,
|
|
263
|
+
AbortSignal.timeout(
|
|
264
|
+
plugins.serveZoneInterfaces.data.coreMailLimits.transferOverallTimeoutMs,
|
|
265
|
+
),
|
|
266
|
+
]);
|
|
267
|
+
const task = (async (): Promise<Response> => {
|
|
268
|
+
try {
|
|
269
|
+
await this.storage.putExact(
|
|
270
|
+
grantArg.objectKey,
|
|
271
|
+
contextArg.request.body as ReadableStream<Uint8Array>,
|
|
272
|
+
grantArg.lengthBytes,
|
|
273
|
+
grantArg.expectedSha256,
|
|
274
|
+
grantArg.contentType,
|
|
275
|
+
maximumBytesForPurpose(grantArg.purpose),
|
|
276
|
+
signal,
|
|
277
|
+
);
|
|
278
|
+
const consumed = await this.models.TransferGrant.exact.transition({
|
|
279
|
+
current: grantArg,
|
|
280
|
+
change: (modelArg) => {
|
|
281
|
+
modelArg.state = 'consumed';
|
|
282
|
+
modelArg.consumedAt = this.now();
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
if (consumed.status !== 'transitioned') {
|
|
286
|
+
throw new Error('CoreMail transfer completion fence changed.');
|
|
287
|
+
}
|
|
288
|
+
return new Response(null, { status: 204 });
|
|
289
|
+
} catch {
|
|
290
|
+
operationController.abort();
|
|
291
|
+
await this.failGrant(grantArg.grantId);
|
|
292
|
+
return new Response('Transfer failed', { status: 409 });
|
|
293
|
+
}
|
|
294
|
+
})();
|
|
295
|
+
this.track(task, operationController);
|
|
296
|
+
return await task;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async handleDownload(
|
|
300
|
+
contextArg: plugins.typedserver.IRequestContext,
|
|
301
|
+
grantArg: TStoredTransferGrant,
|
|
302
|
+
): Promise<Response> {
|
|
303
|
+
const operationController = new AbortController();
|
|
304
|
+
const signal = AbortSignal.any([
|
|
305
|
+
contextArg.request.signal,
|
|
306
|
+
this.stopController.signal,
|
|
307
|
+
operationController.signal,
|
|
308
|
+
AbortSignal.timeout(
|
|
309
|
+
plugins.serveZoneInterfaces.data.coreMailLimits.transferOverallTimeoutMs,
|
|
310
|
+
),
|
|
311
|
+
]);
|
|
312
|
+
try {
|
|
313
|
+
const verified = await this.storage.getVerifiedStream(
|
|
314
|
+
grantArg.objectKey,
|
|
315
|
+
grantArg.lengthBytes,
|
|
316
|
+
grantArg.expectedSha256,
|
|
317
|
+
maximumBytesForPurpose(grantArg.purpose),
|
|
318
|
+
signal,
|
|
319
|
+
);
|
|
320
|
+
const completion = verified.completion.then(async () => {
|
|
321
|
+
const consumed = await this.models.TransferGrant.exact.transition({
|
|
322
|
+
current: grantArg,
|
|
323
|
+
change: (modelArg) => {
|
|
324
|
+
modelArg.state = 'consumed';
|
|
325
|
+
modelArg.consumedAt = this.now();
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
if (consumed.status !== 'transitioned') {
|
|
329
|
+
throw new Error('CoreMail transfer completion fence changed.');
|
|
330
|
+
}
|
|
331
|
+
}).catch(async () => {
|
|
332
|
+
await this.failGrant(grantArg.grantId);
|
|
333
|
+
});
|
|
334
|
+
this.track(completion, operationController);
|
|
335
|
+
return new Response(verified.stream, {
|
|
336
|
+
status: 200,
|
|
337
|
+
headers: {
|
|
338
|
+
'Content-Type': grantArg.contentType,
|
|
339
|
+
'Content-Length': String(grantArg.lengthBytes),
|
|
340
|
+
'Cache-Control': 'no-store',
|
|
341
|
+
'X-Content-Type-Options': 'nosniff',
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
} catch {
|
|
345
|
+
operationController.abort();
|
|
346
|
+
await this.failGrant(grantArg.grantId);
|
|
347
|
+
return new Response('Transfer failed', { status: 409 });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private async failGrant(grantIdArg: string): Promise<void> {
|
|
352
|
+
try {
|
|
353
|
+
const grant = await this.models.TransferGrant.exact.findStoredOne({
|
|
354
|
+
grantId: grantIdArg,
|
|
355
|
+
state: { $in: ['issued', 'transferring'] },
|
|
356
|
+
});
|
|
357
|
+
if (!grant) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const failed = await this.models.TransferGrant.exact.transition({
|
|
361
|
+
current: grant,
|
|
362
|
+
change: (modelArg) => {
|
|
363
|
+
modelArg.state = 'failed';
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
if (failed.status === 'concurrent_change') {
|
|
367
|
+
const current = await this.models.TransferGrant.exact.findStoredOne({
|
|
368
|
+
grantId: grantIdArg,
|
|
369
|
+
});
|
|
370
|
+
if (current?.state === 'issued' || current?.state === 'transferring') {
|
|
371
|
+
throw new Error('CoreMail transfer failure fence changed.');
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
await logCoreMailFailure('transfer', 'GRANT_FAILURE_PERSISTENCE_FAILED')
|
|
376
|
+
.catch(() => undefined);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
requireCoreMailIdentifier,
|
|
4
|
+
requireCoreMailSafeInteger,
|
|
5
|
+
requireCoreMailUuid,
|
|
6
|
+
} from './coremail.selectors.js';
|
|
7
|
+
|
|
8
|
+
export interface ICoreMailInboundCursorAuthority {
|
|
9
|
+
tenantId: string;
|
|
10
|
+
serviceId: string;
|
|
11
|
+
bindingId: string;
|
|
12
|
+
bindingRevision: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ICoreMailInboundCursorPosition {
|
|
16
|
+
snapshotAt: number;
|
|
17
|
+
receivedAt: number;
|
|
18
|
+
deliveryId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ICoreMailInboundCursorPayload
|
|
22
|
+
extends ICoreMailInboundCursorAuthority, ICoreMailInboundCursorPosition {
|
|
23
|
+
version: 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ICoreMailInboundCursorEnvelope {
|
|
27
|
+
version: 1;
|
|
28
|
+
keyId: string;
|
|
29
|
+
keyVersion: number;
|
|
30
|
+
payload: string;
|
|
31
|
+
signature: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type TCursorKey = plugins.serveZoneInterfaces.data.ICoreMailRuntimeKeyReference;
|
|
35
|
+
|
|
36
|
+
const maximumCursorAgeMs = 24 * 60 * 60 * 1_000;
|
|
37
|
+
const maximumFutureSkewMs = 1_000;
|
|
38
|
+
const payloadKeys = new Set([
|
|
39
|
+
'version',
|
|
40
|
+
'tenantId',
|
|
41
|
+
'serviceId',
|
|
42
|
+
'bindingId',
|
|
43
|
+
'bindingRevision',
|
|
44
|
+
'snapshotAt',
|
|
45
|
+
'receivedAt',
|
|
46
|
+
'deliveryId',
|
|
47
|
+
]);
|
|
48
|
+
const envelopeKeys = new Set([
|
|
49
|
+
'version',
|
|
50
|
+
'keyId',
|
|
51
|
+
'keyVersion',
|
|
52
|
+
'payload',
|
|
53
|
+
'signature',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const canonicalPayload = (
|
|
57
|
+
payloadArg: ICoreMailInboundCursorPayload,
|
|
58
|
+
): string => JSON.stringify({
|
|
59
|
+
version: payloadArg.version,
|
|
60
|
+
tenantId: payloadArg.tenantId,
|
|
61
|
+
serviceId: payloadArg.serviceId,
|
|
62
|
+
bindingId: payloadArg.bindingId,
|
|
63
|
+
bindingRevision: payloadArg.bindingRevision,
|
|
64
|
+
snapshotAt: payloadArg.snapshotAt,
|
|
65
|
+
receivedAt: payloadArg.receivedAt,
|
|
66
|
+
deliveryId: payloadArg.deliveryId,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const canonicalEnvelope = (
|
|
70
|
+
envelopeArg: ICoreMailInboundCursorEnvelope,
|
|
71
|
+
): string => JSON.stringify({
|
|
72
|
+
version: envelopeArg.version,
|
|
73
|
+
keyId: envelopeArg.keyId,
|
|
74
|
+
keyVersion: envelopeArg.keyVersion,
|
|
75
|
+
payload: envelopeArg.payload,
|
|
76
|
+
signature: envelopeArg.signature,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const requireAuthority = (
|
|
80
|
+
authorityArg: ICoreMailInboundCursorAuthority,
|
|
81
|
+
): ICoreMailInboundCursorAuthority => ({
|
|
82
|
+
tenantId: requireCoreMailIdentifier(
|
|
83
|
+
authorityArg.tenantId,
|
|
84
|
+
'cursor.tenantId',
|
|
85
|
+
),
|
|
86
|
+
serviceId: requireCoreMailIdentifier(
|
|
87
|
+
authorityArg.serviceId,
|
|
88
|
+
'cursor.serviceId',
|
|
89
|
+
),
|
|
90
|
+
bindingId: requireCoreMailIdentifier(
|
|
91
|
+
authorityArg.bindingId,
|
|
92
|
+
'cursor.bindingId',
|
|
93
|
+
),
|
|
94
|
+
bindingRevision: requireCoreMailSafeInteger(
|
|
95
|
+
authorityArg.bindingRevision,
|
|
96
|
+
'cursor.bindingRevision',
|
|
97
|
+
1,
|
|
98
|
+
),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const parseCanonicalRecord = (
|
|
102
|
+
encodedArg: string,
|
|
103
|
+
keysArg: Set<string>,
|
|
104
|
+
): Record<string, unknown> => {
|
|
105
|
+
let decoded: Buffer;
|
|
106
|
+
let parsed: unknown;
|
|
107
|
+
try {
|
|
108
|
+
decoded = Buffer.from(encodedArg, 'base64url');
|
|
109
|
+
if (decoded.toString('base64url') !== encodedArg) {
|
|
110
|
+
throw new Error('noncanonical encoding');
|
|
111
|
+
}
|
|
112
|
+
parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decoded));
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error('CoreMail inbound cursor is invalid.');
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
!parsed
|
|
118
|
+
|| typeof parsed !== 'object'
|
|
119
|
+
|| Array.isArray(parsed)
|
|
120
|
+
|| Object.getPrototypeOf(parsed) !== Object.prototype
|
|
121
|
+
|| Object.keys(parsed).length !== keysArg.size
|
|
122
|
+
|| Object.keys(parsed).some((keyArg) => !keysArg.has(keyArg))
|
|
123
|
+
) {
|
|
124
|
+
throw new Error('CoreMail inbound cursor is invalid.');
|
|
125
|
+
}
|
|
126
|
+
return parsed as Record<string, unknown>;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const parsePayload = (encodedArg: string): ICoreMailInboundCursorPayload => {
|
|
130
|
+
const value = parseCanonicalRecord(encodedArg, payloadKeys);
|
|
131
|
+
if (value.version !== 1) {
|
|
132
|
+
throw new Error('CoreMail inbound cursor version is unsupported.');
|
|
133
|
+
}
|
|
134
|
+
const payload: ICoreMailInboundCursorPayload = {
|
|
135
|
+
version: 1,
|
|
136
|
+
tenantId: requireCoreMailIdentifier(value.tenantId, 'cursor.tenantId'),
|
|
137
|
+
serviceId: requireCoreMailIdentifier(value.serviceId, 'cursor.serviceId'),
|
|
138
|
+
bindingId: requireCoreMailIdentifier(value.bindingId, 'cursor.bindingId'),
|
|
139
|
+
bindingRevision: requireCoreMailSafeInteger(
|
|
140
|
+
value.bindingRevision,
|
|
141
|
+
'cursor.bindingRevision',
|
|
142
|
+
1,
|
|
143
|
+
),
|
|
144
|
+
snapshotAt: requireCoreMailSafeInteger(
|
|
145
|
+
value.snapshotAt,
|
|
146
|
+
'cursor.snapshotAt',
|
|
147
|
+
),
|
|
148
|
+
receivedAt: requireCoreMailSafeInteger(
|
|
149
|
+
value.receivedAt,
|
|
150
|
+
'cursor.receivedAt',
|
|
151
|
+
),
|
|
152
|
+
deliveryId: requireCoreMailUuid(value.deliveryId, 'cursor.deliveryId'),
|
|
153
|
+
};
|
|
154
|
+
if (canonicalPayload(payload) !== Buffer.from(encodedArg, 'base64url').toString('utf8')) {
|
|
155
|
+
throw new Error('CoreMail inbound cursor is not canonical.');
|
|
156
|
+
}
|
|
157
|
+
return payload;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const deriveHmacKey = (secretArg: string): Buffer => {
|
|
161
|
+
if (
|
|
162
|
+
typeof secretArg !== 'string'
|
|
163
|
+
|| Buffer.byteLength(secretArg, 'utf8') < 32
|
|
164
|
+
|| Buffer.byteLength(secretArg, 'utf8') > 512
|
|
165
|
+
|| secretArg.trim() !== secretArg
|
|
166
|
+
) {
|
|
167
|
+
throw new Error('CoreMail cursor secret is invalid.');
|
|
168
|
+
}
|
|
169
|
+
return plugins.nodeCrypto.createHash('sha256').update(secretArg, 'utf8').digest();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const signatureInput = (
|
|
173
|
+
keyIdArg: string,
|
|
174
|
+
keyVersionArg: number,
|
|
175
|
+
payloadArg: string,
|
|
176
|
+
): string => `${keyIdArg}:${keyVersionArg}:${payloadArg}`;
|
|
177
|
+
|
|
178
|
+
const sign = (
|
|
179
|
+
keyArg: Buffer,
|
|
180
|
+
keyIdArg: string,
|
|
181
|
+
keyVersionArg: number,
|
|
182
|
+
payloadArg: string,
|
|
183
|
+
): string => plugins.nodeCrypto
|
|
184
|
+
.createHmac('sha256', keyArg)
|
|
185
|
+
.update(signatureInput(keyIdArg, keyVersionArg, payloadArg), 'ascii')
|
|
186
|
+
.digest('base64url');
|
|
187
|
+
|
|
188
|
+
export class CoreMailInboundCursorCodec {
|
|
189
|
+
public constructor(
|
|
190
|
+
private readonly resolveRuntimeSecret: (secretKeyArg: string) => string,
|
|
191
|
+
) {}
|
|
192
|
+
|
|
193
|
+
public encode(
|
|
194
|
+
authorityArg: ICoreMailInboundCursorAuthority,
|
|
195
|
+
positionArg: ICoreMailInboundCursorPosition,
|
|
196
|
+
keysArg: TCursorKey[],
|
|
197
|
+
): string {
|
|
198
|
+
const currentKey = keysArg.find((keyArg) => keyArg.state === 'current');
|
|
199
|
+
if (!currentKey) {
|
|
200
|
+
throw new Error('CoreMail current cursor key is unavailable.');
|
|
201
|
+
}
|
|
202
|
+
const authority = requireAuthority(authorityArg);
|
|
203
|
+
const payload: ICoreMailInboundCursorPayload = {
|
|
204
|
+
version: 1,
|
|
205
|
+
...authority,
|
|
206
|
+
snapshotAt: requireCoreMailSafeInteger(
|
|
207
|
+
positionArg.snapshotAt,
|
|
208
|
+
'cursor.snapshotAt',
|
|
209
|
+
),
|
|
210
|
+
receivedAt: requireCoreMailSafeInteger(
|
|
211
|
+
positionArg.receivedAt,
|
|
212
|
+
'cursor.receivedAt',
|
|
213
|
+
),
|
|
214
|
+
deliveryId: requireCoreMailUuid(
|
|
215
|
+
positionArg.deliveryId,
|
|
216
|
+
'cursor.deliveryId',
|
|
217
|
+
),
|
|
218
|
+
};
|
|
219
|
+
if (payload.receivedAt >= payload.snapshotAt) {
|
|
220
|
+
throw new Error('CoreMail inbound cursor position is invalid.');
|
|
221
|
+
}
|
|
222
|
+
const encodedPayload = Buffer.from(
|
|
223
|
+
canonicalPayload(payload),
|
|
224
|
+
'utf8',
|
|
225
|
+
).toString('base64url');
|
|
226
|
+
const envelope: ICoreMailInboundCursorEnvelope = {
|
|
227
|
+
version: 1,
|
|
228
|
+
keyId: currentKey.keyId,
|
|
229
|
+
keyVersion: currentKey.version,
|
|
230
|
+
payload: encodedPayload,
|
|
231
|
+
signature: sign(
|
|
232
|
+
deriveHmacKey(this.resolveRuntimeSecret(currentKey.secretKey)),
|
|
233
|
+
currentKey.keyId,
|
|
234
|
+
currentKey.version,
|
|
235
|
+
encodedPayload,
|
|
236
|
+
),
|
|
237
|
+
};
|
|
238
|
+
return plugins.serveZoneInterfaces.data.normalizeCoreMailInboundCursor(
|
|
239
|
+
Buffer.from(canonicalEnvelope(envelope), 'utf8').toString('base64url'),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
public decode(
|
|
244
|
+
cursorArg: string,
|
|
245
|
+
authorityArg: ICoreMailInboundCursorAuthority,
|
|
246
|
+
nowArg: number,
|
|
247
|
+
keysArg: TCursorKey[],
|
|
248
|
+
): ICoreMailInboundCursorPosition {
|
|
249
|
+
const cursor = plugins.serveZoneInterfaces.data.normalizeCoreMailInboundCursor(
|
|
250
|
+
cursorArg,
|
|
251
|
+
);
|
|
252
|
+
const value = parseCanonicalRecord(cursor, envelopeKeys);
|
|
253
|
+
if (value.version !== 1) {
|
|
254
|
+
throw new Error('CoreMail inbound cursor version is unsupported.');
|
|
255
|
+
}
|
|
256
|
+
const now = requireCoreMailSafeInteger(nowArg, 'cursor.now');
|
|
257
|
+
const keyId = requireCoreMailIdentifier(value.keyId, 'cursor.keyId');
|
|
258
|
+
const keyVersion = requireCoreMailSafeInteger(
|
|
259
|
+
value.keyVersion,
|
|
260
|
+
'cursor.keyVersion',
|
|
261
|
+
1,
|
|
262
|
+
);
|
|
263
|
+
if (typeof value.payload !== 'string' || typeof value.signature !== 'string') {
|
|
264
|
+
throw new Error('CoreMail inbound cursor is invalid.');
|
|
265
|
+
}
|
|
266
|
+
const envelope: ICoreMailInboundCursorEnvelope = {
|
|
267
|
+
version: 1,
|
|
268
|
+
keyId,
|
|
269
|
+
keyVersion,
|
|
270
|
+
payload: value.payload,
|
|
271
|
+
signature: value.signature,
|
|
272
|
+
};
|
|
273
|
+
if (canonicalEnvelope(envelope) !== Buffer.from(cursor, 'base64url').toString('utf8')) {
|
|
274
|
+
throw new Error('CoreMail inbound cursor is not canonical.');
|
|
275
|
+
}
|
|
276
|
+
const key = keysArg.find((keyArg) =>
|
|
277
|
+
keyArg.keyId === keyId
|
|
278
|
+
&& keyArg.version === keyVersion
|
|
279
|
+
&& (
|
|
280
|
+
keyArg.state === 'current'
|
|
281
|
+
|| (
|
|
282
|
+
keyArg.state === 'retiring'
|
|
283
|
+
&& keyArg.acceptUntil !== undefined
|
|
284
|
+
&& keyArg.acceptUntil >= now
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
if (!key) {
|
|
289
|
+
throw new Error('CoreMail inbound cursor key is unavailable.');
|
|
290
|
+
}
|
|
291
|
+
const actualSignature = Buffer.from(envelope.signature, 'base64url');
|
|
292
|
+
const expectedSignature = Buffer.from(sign(
|
|
293
|
+
deriveHmacKey(this.resolveRuntimeSecret(key.secretKey)),
|
|
294
|
+
key.keyId,
|
|
295
|
+
key.version,
|
|
296
|
+
envelope.payload,
|
|
297
|
+
), 'base64url');
|
|
298
|
+
if (
|
|
299
|
+
actualSignature.toString('base64url') !== envelope.signature
|
|
300
|
+
|| actualSignature.byteLength !== expectedSignature.byteLength
|
|
301
|
+
|| !plugins.nodeCrypto.timingSafeEqual(actualSignature, expectedSignature)
|
|
302
|
+
) {
|
|
303
|
+
throw new Error('CoreMail inbound cursor is invalid.');
|
|
304
|
+
}
|
|
305
|
+
const payload = parsePayload(envelope.payload);
|
|
306
|
+
const authority = requireAuthority(authorityArg);
|
|
307
|
+
if (
|
|
308
|
+
payload.tenantId !== authority.tenantId
|
|
309
|
+
|| payload.serviceId !== authority.serviceId
|
|
310
|
+
|| payload.bindingId !== authority.bindingId
|
|
311
|
+
|| payload.bindingRevision !== authority.bindingRevision
|
|
312
|
+
) {
|
|
313
|
+
throw new Error('CoreMail inbound cursor authority is invalid.');
|
|
314
|
+
}
|
|
315
|
+
if (
|
|
316
|
+
payload.receivedAt >= payload.snapshotAt
|
|
317
|
+
|| payload.snapshotAt > now + maximumFutureSkewMs
|
|
318
|
+
|| now - payload.snapshotAt > maximumCursorAgeMs
|
|
319
|
+
) {
|
|
320
|
+
throw new Error('CoreMail inbound cursor window is invalid.');
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
snapshotAt: payload.snapshotAt,
|
|
324
|
+
receivedAt: payload.receivedAt,
|
|
325
|
+
deliveryId: payload.deliveryId,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|