@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.
Files changed (75) hide show
  1. package/.smartconfig.json +50 -0
  2. package/changelog.md +10 -0
  3. package/cli.js +4 -0
  4. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  5. package/dist_ts/00_commitinfo_data.js +9 -0
  6. package/dist_ts/classes.auth.d.ts +45 -0
  7. package/dist_ts/classes.auth.js +204 -0
  8. package/dist_ts/classes.config.d.ts +2 -0
  9. package/dist_ts/classes.config.js +72 -0
  10. package/dist_ts/classes.coremail.d.ts +41 -0
  11. package/dist_ts/classes.coremail.js +155 -0
  12. package/dist_ts/classes.desiredstate.d.ts +40 -0
  13. package/dist_ts/classes.desiredstate.js +382 -0
  14. package/dist_ts/classes.gateway.d.ts +62 -0
  15. package/dist_ts/classes.gateway.js +669 -0
  16. package/dist_ts/classes.inbound.d.ts +49 -0
  17. package/dist_ts/classes.inbound.js +702 -0
  18. package/dist_ts/classes.maintenance.d.ts +16 -0
  19. package/dist_ts/classes.maintenance.js +339 -0
  20. package/dist_ts/classes.models.d.ts +161 -0
  21. package/dist_ts/classes.models.js +595 -0
  22. package/dist_ts/classes.server.d.ts +40 -0
  23. package/dist_ts/classes.server.js +309 -0
  24. package/dist_ts/classes.storage.d.ts +32 -0
  25. package/dist_ts/classes.storage.js +317 -0
  26. package/dist_ts/classes.submissions.d.ts +33 -0
  27. package/dist_ts/classes.submissions.js +385 -0
  28. package/dist_ts/classes.transfer.d.ts +41 -0
  29. package/dist_ts/classes.transfer.js +289 -0
  30. package/dist_ts/coremail.cursor.d.ts +20 -0
  31. package/dist_ts/coremail.cursor.js +191 -0
  32. package/dist_ts/coremail.log.d.ts +3 -0
  33. package/dist_ts/coremail.log.js +11 -0
  34. package/dist_ts/coremail.mime.d.ts +8 -0
  35. package/dist_ts/coremail.mime.js +63 -0
  36. package/dist_ts/coremail.persistence.d.ts +148 -0
  37. package/dist_ts/coremail.persistence.js +695 -0
  38. package/dist_ts/coremail.quota.d.ts +29 -0
  39. package/dist_ts/coremail.quota.js +149 -0
  40. package/dist_ts/coremail.selectors.d.ts +3 -0
  41. package/dist_ts/coremail.selectors.js +21 -0
  42. package/dist_ts/coremail.validation.d.ts +9 -0
  43. package/dist_ts/coremail.validation.js +325 -0
  44. package/dist_ts/index.d.ts +4 -0
  45. package/dist_ts/index.js +39 -0
  46. package/dist_ts/interfaces.d.ts +92 -0
  47. package/dist_ts/interfaces.js +2 -0
  48. package/dist_ts/plugins.d.ts +11 -0
  49. package/dist_ts/plugins.js +12 -0
  50. package/license.md +19 -0
  51. package/package.json +48 -0
  52. package/readme.md +199 -0
  53. package/ts/00_commitinfo_data.ts +8 -0
  54. package/ts/classes.auth.ts +321 -0
  55. package/ts/classes.config.ts +104 -0
  56. package/ts/classes.coremail.ts +249 -0
  57. package/ts/classes.desiredstate.ts +508 -0
  58. package/ts/classes.gateway.ts +811 -0
  59. package/ts/classes.inbound.ts +898 -0
  60. package/ts/classes.maintenance.ts +335 -0
  61. package/ts/classes.models.ts +530 -0
  62. package/ts/classes.server.ts +444 -0
  63. package/ts/classes.storage.ts +448 -0
  64. package/ts/classes.submissions.ts +554 -0
  65. package/ts/classes.transfer.ts +379 -0
  66. package/ts/coremail.cursor.ts +328 -0
  67. package/ts/coremail.log.ts +21 -0
  68. package/ts/coremail.mime.ts +94 -0
  69. package/ts/coremail.persistence.ts +1156 -0
  70. package/ts/coremail.quota.ts +214 -0
  71. package/ts/coremail.selectors.ts +35 -0
  72. package/ts/coremail.validation.ts +446 -0
  73. package/ts/index.ts +38 -0
  74. package/ts/interfaces.ts +109 -0
  75. 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
+ }