@serve.zone/interfaces 20.2.0 → 21.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/changelog.md +10 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/coremail.d.ts +37 -0
- package/dist_ts/data/coremail.js +15 -1
- package/dist_ts/data/coremail.runtime.d.ts +31 -0
- package/dist_ts/data/coremail.runtime.js +419 -0
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/requests/coremail.d.ts +2 -0
- package/package.json +1 -1
- package/readme.md +34 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/coremail.runtime.ts +675 -0
- package/ts/data/coremail.ts +41 -0
- package/ts/data/index.ts +1 -0
- package/ts/requests/coremail.ts +2 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalizeStrictJson,
|
|
3
|
+
createCanonicalJsonSha256Hex,
|
|
4
|
+
deepFreezeValue,
|
|
5
|
+
strictCanonicalJsonRules,
|
|
6
|
+
} from '../private/canonicaljson.js';
|
|
7
|
+
import {
|
|
8
|
+
coreMailCredentialVerifierPolicy,
|
|
9
|
+
type ICoreMailBindingCredentialVerifier,
|
|
10
|
+
type ICoreMailBindingDesiredState,
|
|
11
|
+
type ICoreMailControlBootstrap,
|
|
12
|
+
type ICoreMailDesiredState,
|
|
13
|
+
type ICoreMailGatewayDesiredState,
|
|
14
|
+
type ICoreMailGatewayPeerDesiredState,
|
|
15
|
+
type TCoreMailCapability,
|
|
16
|
+
type TCoreMailSha256,
|
|
17
|
+
} from './coremail.js';
|
|
18
|
+
|
|
19
|
+
export const coreMailCanonicalJsonRules = strictCanonicalJsonRules;
|
|
20
|
+
|
|
21
|
+
export const coreMailContractLimits = Object.freeze({
|
|
22
|
+
maximumIdentifierBytes: 128,
|
|
23
|
+
maximumEnvironmentKeyBytes: 128,
|
|
24
|
+
maximumEndpointBytes: 2_048,
|
|
25
|
+
maximumMailboxBytes: 254,
|
|
26
|
+
maximumBindings: 10_000,
|
|
27
|
+
maximumCredentialsPerAuthority: 16,
|
|
28
|
+
maximumAllowedSendersPerBinding: 1_000,
|
|
29
|
+
maximumInboundRecipientsPerBinding: 10_000,
|
|
30
|
+
} as const);
|
|
31
|
+
|
|
32
|
+
export class CoreMailContractError extends Error {
|
|
33
|
+
public constructor(reasonArg: string) {
|
|
34
|
+
super(`CoreMail contract error: ${reasonArg}`);
|
|
35
|
+
this.name = 'CoreMailContractError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type TStrictRecord = Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
const fail = (reasonArg: string): never => {
|
|
42
|
+
throw new CoreMailContractError(reasonArg);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const compareStrings = (leftArg: string, rightArg: string): number =>
|
|
46
|
+
leftArg < rightArg ? -1 : leftArg > rightArg ? 1 : 0;
|
|
47
|
+
|
|
48
|
+
const readRecord = (valueArg: unknown, pathArg: string): TStrictRecord => {
|
|
49
|
+
if (
|
|
50
|
+
!valueArg
|
|
51
|
+
|| typeof valueArg !== 'object'
|
|
52
|
+
|| Array.isArray(valueArg)
|
|
53
|
+
|| (
|
|
54
|
+
Object.getPrototypeOf(valueArg) !== Object.prototype
|
|
55
|
+
&& Object.getPrototypeOf(valueArg) !== null
|
|
56
|
+
)
|
|
57
|
+
) {
|
|
58
|
+
return fail(`${pathArg} must be a plain object`);
|
|
59
|
+
}
|
|
60
|
+
return valueArg as TStrictRecord;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const assertKeys = (
|
|
64
|
+
recordArg: TStrictRecord,
|
|
65
|
+
pathArg: string,
|
|
66
|
+
requiredKeysArg: string[],
|
|
67
|
+
optionalKeysArg: string[] = [],
|
|
68
|
+
): void => {
|
|
69
|
+
const allowedKeys = new Set([...requiredKeysArg, ...optionalKeysArg]);
|
|
70
|
+
const keys = Reflect.ownKeys(recordArg);
|
|
71
|
+
if (keys.some((keyArg) => typeof keyArg !== 'string')) {
|
|
72
|
+
fail(`${pathArg} must not contain symbol keys`);
|
|
73
|
+
}
|
|
74
|
+
for (const requiredKey of requiredKeysArg) {
|
|
75
|
+
if (!Object.hasOwn(recordArg, requiredKey)) {
|
|
76
|
+
fail(`${pathArg}.${requiredKey} is required`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const key of keys as string[]) {
|
|
80
|
+
const descriptor = Object.getOwnPropertyDescriptor(recordArg, key);
|
|
81
|
+
if (
|
|
82
|
+
!descriptor
|
|
83
|
+
|| !descriptor.enumerable
|
|
84
|
+
|| !Object.hasOwn(descriptor, 'value')
|
|
85
|
+
) {
|
|
86
|
+
fail(`${pathArg}.${key} must be an enumerable data property`);
|
|
87
|
+
}
|
|
88
|
+
if (!allowedKeys.has(key)) {
|
|
89
|
+
fail(`${pathArg}.${key} is not allowed`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const readArray = (
|
|
95
|
+
valueArg: unknown,
|
|
96
|
+
pathArg: string,
|
|
97
|
+
maximumLengthArg: number,
|
|
98
|
+
): unknown[] => {
|
|
99
|
+
if (!Array.isArray(valueArg) || Object.getPrototypeOf(valueArg) !== Array.prototype) {
|
|
100
|
+
return fail(`${pathArg} must be a plain array`);
|
|
101
|
+
}
|
|
102
|
+
if (valueArg.length > maximumLengthArg) {
|
|
103
|
+
fail(`${pathArg} exceeds its maximum length of ${maximumLengthArg}`);
|
|
104
|
+
}
|
|
105
|
+
for (let index = 0; index < valueArg.length; index++) {
|
|
106
|
+
if (!Object.hasOwn(valueArg, index)) {
|
|
107
|
+
fail(`${pathArg} must not contain sparse entries`);
|
|
108
|
+
}
|
|
109
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, String(index));
|
|
110
|
+
if (
|
|
111
|
+
!descriptor
|
|
112
|
+
|| !descriptor.enumerable
|
|
113
|
+
|| !Object.hasOwn(descriptor, 'value')
|
|
114
|
+
) {
|
|
115
|
+
fail(`${pathArg}[${index}] must be an enumerable data property`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const key of Reflect.ownKeys(valueArg)) {
|
|
119
|
+
if (key === 'length') {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
typeof key !== 'string'
|
|
124
|
+
|| !/^(?:0|[1-9][0-9]*)$/.test(key)
|
|
125
|
+
|| Number(key) >= valueArg.length
|
|
126
|
+
) {
|
|
127
|
+
fail(`${pathArg} must not contain extra properties`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return valueArg;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const requireString = (
|
|
134
|
+
valueArg: unknown,
|
|
135
|
+
pathArg: string,
|
|
136
|
+
maximumBytesArg: number,
|
|
137
|
+
): string => {
|
|
138
|
+
if (typeof valueArg !== 'string' || valueArg.length === 0) {
|
|
139
|
+
return fail(`${pathArg} must be a non-empty string`);
|
|
140
|
+
}
|
|
141
|
+
if (new TextEncoder().encode(valueArg).byteLength > maximumBytesArg) {
|
|
142
|
+
fail(`${pathArg} exceeds ${maximumBytesArg} UTF-8 bytes`);
|
|
143
|
+
}
|
|
144
|
+
return valueArg;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const identifierRegex = /^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,126}[A-Za-z0-9])?$/;
|
|
148
|
+
|
|
149
|
+
const requireIdentifier = (valueArg: unknown, pathArg: string): string => {
|
|
150
|
+
const value = requireString(
|
|
151
|
+
valueArg,
|
|
152
|
+
pathArg,
|
|
153
|
+
coreMailContractLimits.maximumIdentifierBytes,
|
|
154
|
+
);
|
|
155
|
+
if (!identifierRegex.test(value)) {
|
|
156
|
+
fail(`${pathArg} must be a canonical identifier`);
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const requireSafeInteger = (
|
|
162
|
+
valueArg: unknown,
|
|
163
|
+
pathArg: string,
|
|
164
|
+
minimumArg: number,
|
|
165
|
+
): number => {
|
|
166
|
+
if (!Number.isSafeInteger(valueArg) || (valueArg as number) < minimumArg) {
|
|
167
|
+
return fail(`${pathArg} must be a safe integer greater than or equal to ${minimumArg}`);
|
|
168
|
+
}
|
|
169
|
+
return valueArg as number;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const decodeCanonicalBase64 = (
|
|
173
|
+
valueArg: string,
|
|
174
|
+
expectedBytesArg: number,
|
|
175
|
+
pathArg: string,
|
|
176
|
+
): void => {
|
|
177
|
+
if (!/^[A-Za-z0-9+/]+$/.test(valueArg)) {
|
|
178
|
+
fail(`${pathArg} must use unpadded standard base64`);
|
|
179
|
+
}
|
|
180
|
+
const paddedValue = `${valueArg}${'='.repeat((4 - (valueArg.length % 4)) % 4)}`;
|
|
181
|
+
let decoded: string;
|
|
182
|
+
try {
|
|
183
|
+
decoded = globalThis.atob(paddedValue);
|
|
184
|
+
} catch {
|
|
185
|
+
return fail(`${pathArg} must be valid base64`);
|
|
186
|
+
}
|
|
187
|
+
if (decoded.length !== expectedBytesArg) {
|
|
188
|
+
fail(`${pathArg} must encode exactly ${expectedBytesArg} bytes`);
|
|
189
|
+
}
|
|
190
|
+
const canonical = globalThis.btoa(decoded).replace(/=+$/u, '');
|
|
191
|
+
if (canonical !== valueArg) {
|
|
192
|
+
fail(`${pathArg} must be canonical unpadded base64`);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const normalizeCoreMailSha256 = (
|
|
197
|
+
valueArg: unknown,
|
|
198
|
+
pathArg = 'sha256',
|
|
199
|
+
): TCoreMailSha256 => {
|
|
200
|
+
if (
|
|
201
|
+
typeof valueArg !== 'string'
|
|
202
|
+
|| !/^sha256:[0-9a-f]{64}$/.test(valueArg)
|
|
203
|
+
) {
|
|
204
|
+
return fail(`${pathArg} must be a canonical sha256:<64 lowercase hex> digest`);
|
|
205
|
+
}
|
|
206
|
+
return valueArg as TCoreMailSha256;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const normalizeCoreMailCredentialVerifier = (
|
|
210
|
+
valueArg: unknown,
|
|
211
|
+
pathArg = 'credential',
|
|
212
|
+
): ICoreMailBindingCredentialVerifier => {
|
|
213
|
+
const record = readRecord(valueArg, pathArg);
|
|
214
|
+
assertKeys(
|
|
215
|
+
record,
|
|
216
|
+
pathArg,
|
|
217
|
+
['credentialId', 'version', 'state', 'format', 'verificationHash'],
|
|
218
|
+
['acceptUntil'],
|
|
219
|
+
);
|
|
220
|
+
const credentialId = requireIdentifier(record.credentialId, `${pathArg}.credentialId`);
|
|
221
|
+
const version = requireSafeInteger(record.version, `${pathArg}.version`, 1);
|
|
222
|
+
const state = record.state === 'current' || record.state === 'retiring'
|
|
223
|
+
? record.state
|
|
224
|
+
: fail(`${pathArg}.state must be current or retiring`);
|
|
225
|
+
const format = record.format === coreMailCredentialVerifierPolicy.format
|
|
226
|
+
? record.format
|
|
227
|
+
: fail(`${pathArg}.format must be ${coreMailCredentialVerifierPolicy.format}`);
|
|
228
|
+
const verificationHash = requireString(
|
|
229
|
+
record.verificationHash,
|
|
230
|
+
`${pathArg}.verificationHash`,
|
|
231
|
+
512,
|
|
232
|
+
);
|
|
233
|
+
const match = verificationHash.match(
|
|
234
|
+
/^\$argon2id\$v=19\$m=65536,t=3,p=1\$([A-Za-z0-9+/]+)\$([A-Za-z0-9+/]+)$/,
|
|
235
|
+
);
|
|
236
|
+
if (!match) {
|
|
237
|
+
return fail(`${pathArg}.verificationHash must use the canonical argon2id-v1 PHC form`);
|
|
238
|
+
}
|
|
239
|
+
const [, saltBase64, digestBase64] = match;
|
|
240
|
+
decodeCanonicalBase64(
|
|
241
|
+
saltBase64,
|
|
242
|
+
coreMailCredentialVerifierPolicy.saltLengthBytes,
|
|
243
|
+
`${pathArg}.verificationHash salt`,
|
|
244
|
+
);
|
|
245
|
+
decodeCanonicalBase64(
|
|
246
|
+
digestBase64,
|
|
247
|
+
coreMailCredentialVerifierPolicy.hashLengthBytes,
|
|
248
|
+
`${pathArg}.verificationHash digest`,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (state === 'current' && Object.hasOwn(record, 'acceptUntil')) {
|
|
252
|
+
fail(`${pathArg}.acceptUntil is forbidden for a current credential`);
|
|
253
|
+
}
|
|
254
|
+
if (state === 'retiring' && !Object.hasOwn(record, 'acceptUntil')) {
|
|
255
|
+
fail(`${pathArg}.acceptUntil is required for a retiring credential`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const normalized: ICoreMailBindingCredentialVerifier = {
|
|
259
|
+
credentialId,
|
|
260
|
+
version,
|
|
261
|
+
state,
|
|
262
|
+
format,
|
|
263
|
+
verificationHash,
|
|
264
|
+
};
|
|
265
|
+
if (state === 'retiring') {
|
|
266
|
+
normalized.acceptUntil = requireSafeInteger(
|
|
267
|
+
record.acceptUntil,
|
|
268
|
+
`${pathArg}.acceptUntil`,
|
|
269
|
+
1,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return deepFreezeValue(normalized);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const normalizeCredentialSet = (
|
|
276
|
+
valueArg: unknown,
|
|
277
|
+
pathArg: string,
|
|
278
|
+
): ICoreMailBindingCredentialVerifier[] => {
|
|
279
|
+
const credentials = readArray(
|
|
280
|
+
valueArg,
|
|
281
|
+
pathArg,
|
|
282
|
+
coreMailContractLimits.maximumCredentialsPerAuthority,
|
|
283
|
+
).map((entryArg, indexArg) =>
|
|
284
|
+
normalizeCoreMailCredentialVerifier(entryArg, `${pathArg}[${indexArg}]`),
|
|
285
|
+
);
|
|
286
|
+
if (credentials.length === 0) {
|
|
287
|
+
fail(`${pathArg} must contain at least one credential`);
|
|
288
|
+
}
|
|
289
|
+
if (credentials.filter((entryArg) => entryArg.state === 'current').length !== 1) {
|
|
290
|
+
fail(`${pathArg} must contain exactly one current credential`);
|
|
291
|
+
}
|
|
292
|
+
const identities = new Set<string>();
|
|
293
|
+
const versions = new Set<number>();
|
|
294
|
+
for (const credential of credentials) {
|
|
295
|
+
const identity = `${credential.credentialId}:${credential.version}`;
|
|
296
|
+
if (identities.has(identity) || versions.has(credential.version)) {
|
|
297
|
+
fail(`${pathArg} contains a duplicate credential identity or version`);
|
|
298
|
+
}
|
|
299
|
+
identities.add(identity);
|
|
300
|
+
versions.add(credential.version);
|
|
301
|
+
}
|
|
302
|
+
const current = credentials.find((entryArg) => entryArg.state === 'current')!;
|
|
303
|
+
if (credentials.some((entryArg) =>
|
|
304
|
+
entryArg.state === 'retiring' && entryArg.version >= current.version
|
|
305
|
+
)) {
|
|
306
|
+
fail(`${pathArg} current credential must have the greatest version`);
|
|
307
|
+
}
|
|
308
|
+
return credentials.sort((leftArg, rightArg) =>
|
|
309
|
+
leftArg.version - rightArg.version
|
|
310
|
+
|| compareStrings(leftArg.credentialId, rightArg.credentialId),
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const mailboxLocalPartRegex = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+$/;
|
|
315
|
+
const mailboxDomainLabelRegex = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
316
|
+
|
|
317
|
+
const requireCanonicalMailbox = (valueArg: unknown, pathArg: string): string => {
|
|
318
|
+
const value = requireString(
|
|
319
|
+
valueArg,
|
|
320
|
+
pathArg,
|
|
321
|
+
coreMailContractLimits.maximumMailboxBytes,
|
|
322
|
+
);
|
|
323
|
+
const atIndex = value.lastIndexOf('@');
|
|
324
|
+
const localPart = atIndex > 0 ? value.slice(0, atIndex) : '';
|
|
325
|
+
const domain = atIndex > 0 ? value.slice(atIndex + 1) : '';
|
|
326
|
+
const domainLabels = domain.split('.');
|
|
327
|
+
if (
|
|
328
|
+
value !== value.toLowerCase()
|
|
329
|
+
|| localPart.length === 0
|
|
330
|
+
|| localPart.length > 64
|
|
331
|
+
|| !mailboxLocalPartRegex.test(localPart)
|
|
332
|
+
|| localPart.startsWith('.')
|
|
333
|
+
|| localPart.endsWith('.')
|
|
334
|
+
|| localPart.includes('..')
|
|
335
|
+
|| domain.length === 0
|
|
336
|
+
|| domain.length > 253
|
|
337
|
+
|| domainLabels.length < 2
|
|
338
|
+
|| domainLabels.some((labelArg) => !mailboxDomainLabelRegex.test(labelArg))
|
|
339
|
+
) {
|
|
340
|
+
fail(`${pathArg} must be a canonical lowercase mailbox`);
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const normalizeUniqueSortedStrings = (
|
|
346
|
+
valueArg: unknown,
|
|
347
|
+
pathArg: string,
|
|
348
|
+
maximumLengthArg: number,
|
|
349
|
+
normalizeEntryArg: (entryArg: unknown, pathArg: string) => string,
|
|
350
|
+
): string[] => {
|
|
351
|
+
const values = readArray(valueArg, pathArg, maximumLengthArg).map(
|
|
352
|
+
(entryArg, indexArg) => normalizeEntryArg(entryArg, `${pathArg}[${indexArg}]`),
|
|
353
|
+
);
|
|
354
|
+
if (new Set(values).size !== values.length) {
|
|
355
|
+
fail(`${pathArg} must contain unique values`);
|
|
356
|
+
}
|
|
357
|
+
return values.sort();
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const normalizeCapabilities = (
|
|
361
|
+
valueArg: unknown,
|
|
362
|
+
pathArg: string,
|
|
363
|
+
): TCoreMailCapability[] => {
|
|
364
|
+
const capabilities = readArray(valueArg, pathArg, 2).map((entryArg, indexArg) => {
|
|
365
|
+
if (entryArg !== 'outbound' && entryArg !== 'inbound') {
|
|
366
|
+
return fail(`${pathArg}[${indexArg}] must be outbound or inbound`);
|
|
367
|
+
}
|
|
368
|
+
return entryArg;
|
|
369
|
+
});
|
|
370
|
+
if (capabilities.length === 0 || new Set(capabilities).size !== capabilities.length) {
|
|
371
|
+
fail(`${pathArg} must contain unique capabilities`);
|
|
372
|
+
}
|
|
373
|
+
return capabilities.sort();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const normalizeLimits = (
|
|
377
|
+
valueArg: unknown,
|
|
378
|
+
pathArg: string,
|
|
379
|
+
): NonNullable<ICoreMailBindingDesiredState['limits']> => {
|
|
380
|
+
const record = readRecord(valueArg, pathArg);
|
|
381
|
+
assertKeys(
|
|
382
|
+
record,
|
|
383
|
+
pathArg,
|
|
384
|
+
[],
|
|
385
|
+
['messagesPerMinute', 'messagesPerDay', 'maxPendingInbound'],
|
|
386
|
+
);
|
|
387
|
+
if (Object.keys(record).length === 0) {
|
|
388
|
+
fail(`${pathArg} must not be empty`);
|
|
389
|
+
}
|
|
390
|
+
const normalized: NonNullable<ICoreMailBindingDesiredState['limits']> = {};
|
|
391
|
+
for (const key of [
|
|
392
|
+
'messagesPerMinute',
|
|
393
|
+
'messagesPerDay',
|
|
394
|
+
'maxPendingInbound',
|
|
395
|
+
] as const) {
|
|
396
|
+
if (Object.hasOwn(record, key)) {
|
|
397
|
+
normalized[key] = requireSafeInteger(record[key], `${pathArg}.${key}`, 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return normalized;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const normalizeBinding = (
|
|
404
|
+
valueArg: unknown,
|
|
405
|
+
pathArg: string,
|
|
406
|
+
): ICoreMailBindingDesiredState => {
|
|
407
|
+
const record = readRecord(valueArg, pathArg);
|
|
408
|
+
assertKeys(
|
|
409
|
+
record,
|
|
410
|
+
pathArg,
|
|
411
|
+
[
|
|
412
|
+
'schemaVersion',
|
|
413
|
+
'bindingId',
|
|
414
|
+
'serviceId',
|
|
415
|
+
'tenantId',
|
|
416
|
+
'revision',
|
|
417
|
+
'state',
|
|
418
|
+
'capabilities',
|
|
419
|
+
'credentials',
|
|
420
|
+
'allowedSenders',
|
|
421
|
+
'inboundRecipients',
|
|
422
|
+
],
|
|
423
|
+
['defaultSender', 'limits'],
|
|
424
|
+
);
|
|
425
|
+
if (record.schemaVersion !== 1) {
|
|
426
|
+
fail(`${pathArg}.schemaVersion must be 1`);
|
|
427
|
+
}
|
|
428
|
+
const state = record.state === 'active' || record.state === 'disabled'
|
|
429
|
+
? record.state
|
|
430
|
+
: fail(`${pathArg}.state must be active or disabled`);
|
|
431
|
+
const capabilities = normalizeCapabilities(record.capabilities, `${pathArg}.capabilities`);
|
|
432
|
+
const allowedSenders = normalizeUniqueSortedStrings(
|
|
433
|
+
record.allowedSenders,
|
|
434
|
+
`${pathArg}.allowedSenders`,
|
|
435
|
+
coreMailContractLimits.maximumAllowedSendersPerBinding,
|
|
436
|
+
requireCanonicalMailbox,
|
|
437
|
+
);
|
|
438
|
+
const inboundRecipients = normalizeUniqueSortedStrings(
|
|
439
|
+
record.inboundRecipients,
|
|
440
|
+
`${pathArg}.inboundRecipients`,
|
|
441
|
+
coreMailContractLimits.maximumInboundRecipientsPerBinding,
|
|
442
|
+
requireCanonicalMailbox,
|
|
443
|
+
);
|
|
444
|
+
if (!capabilities.includes('outbound') && allowedSenders.length > 0) {
|
|
445
|
+
fail(`${pathArg}.allowedSenders requires the outbound capability`);
|
|
446
|
+
}
|
|
447
|
+
if (!capabilities.includes('inbound') && inboundRecipients.length > 0) {
|
|
448
|
+
fail(`${pathArg}.inboundRecipients requires the inbound capability`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const normalized: ICoreMailBindingDesiredState = {
|
|
452
|
+
schemaVersion: 1,
|
|
453
|
+
bindingId: requireIdentifier(record.bindingId, `${pathArg}.bindingId`),
|
|
454
|
+
serviceId: requireIdentifier(record.serviceId, `${pathArg}.serviceId`),
|
|
455
|
+
tenantId: requireIdentifier(record.tenantId, `${pathArg}.tenantId`),
|
|
456
|
+
revision: requireSafeInteger(record.revision, `${pathArg}.revision`, 1),
|
|
457
|
+
state,
|
|
458
|
+
capabilities,
|
|
459
|
+
credentials: normalizeCredentialSet(record.credentials, `${pathArg}.credentials`),
|
|
460
|
+
allowedSenders,
|
|
461
|
+
inboundRecipients,
|
|
462
|
+
};
|
|
463
|
+
if (Object.hasOwn(record, 'defaultSender')) {
|
|
464
|
+
normalized.defaultSender = requireCanonicalMailbox(
|
|
465
|
+
record.defaultSender,
|
|
466
|
+
`${pathArg}.defaultSender`,
|
|
467
|
+
);
|
|
468
|
+
if (!allowedSenders.includes(normalized.defaultSender)) {
|
|
469
|
+
fail(`${pathArg}.defaultSender must be present in allowedSenders`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (Object.hasOwn(record, 'limits')) {
|
|
473
|
+
normalized.limits = normalizeLimits(record.limits, `${pathArg}.limits`);
|
|
474
|
+
}
|
|
475
|
+
return normalized;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const requireCanonicalUrl = (
|
|
479
|
+
valueArg: unknown,
|
|
480
|
+
pathArg: string,
|
|
481
|
+
protocolArg: 'https:' | 'wss:',
|
|
482
|
+
originOnlyArg: boolean,
|
|
483
|
+
): string => {
|
|
484
|
+
const value = requireString(
|
|
485
|
+
valueArg,
|
|
486
|
+
pathArg,
|
|
487
|
+
coreMailContractLimits.maximumEndpointBytes,
|
|
488
|
+
);
|
|
489
|
+
let url: URL;
|
|
490
|
+
try {
|
|
491
|
+
url = new URL(value);
|
|
492
|
+
} catch {
|
|
493
|
+
return fail(`${pathArg} must be an absolute URL`);
|
|
494
|
+
}
|
|
495
|
+
if (
|
|
496
|
+
url.protocol !== protocolArg
|
|
497
|
+
|| url.username
|
|
498
|
+
|| url.password
|
|
499
|
+
|| url.search
|
|
500
|
+
|| url.hash
|
|
501
|
+
) {
|
|
502
|
+
fail(`${pathArg} must be a credential-free canonical ${protocolArg} URL`);
|
|
503
|
+
}
|
|
504
|
+
if (originOnlyArg && url.pathname !== '/') {
|
|
505
|
+
fail(`${pathArg} must be an origin without a path`);
|
|
506
|
+
}
|
|
507
|
+
const canonical = originOnlyArg
|
|
508
|
+
? url.origin
|
|
509
|
+
: url.toString();
|
|
510
|
+
if (canonical !== value) {
|
|
511
|
+
fail(`${pathArg} must already be in canonical URL form`);
|
|
512
|
+
}
|
|
513
|
+
return canonical;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const normalizeGateway = (
|
|
517
|
+
valueArg: unknown,
|
|
518
|
+
pathArg: string,
|
|
519
|
+
): ICoreMailGatewayDesiredState => {
|
|
520
|
+
const record = readRecord(valueArg, pathArg);
|
|
521
|
+
assertKeys(
|
|
522
|
+
record,
|
|
523
|
+
pathArg,
|
|
524
|
+
['endpointUrl', 'credentialId', 'credentialVersion', 'credentialSecretKey'],
|
|
525
|
+
);
|
|
526
|
+
const credentialSecretKey = requireString(
|
|
527
|
+
record.credentialSecretKey,
|
|
528
|
+
`${pathArg}.credentialSecretKey`,
|
|
529
|
+
coreMailContractLimits.maximumEnvironmentKeyBytes,
|
|
530
|
+
);
|
|
531
|
+
if (!/^[A-Z][A-Z0-9_]{0,127}$/.test(credentialSecretKey)) {
|
|
532
|
+
fail(`${pathArg}.credentialSecretKey must be a canonical environment key`);
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
endpointUrl: requireCanonicalUrl(
|
|
536
|
+
record.endpointUrl,
|
|
537
|
+
`${pathArg}.endpointUrl`,
|
|
538
|
+
'wss:',
|
|
539
|
+
false,
|
|
540
|
+
),
|
|
541
|
+
credentialId: requireIdentifier(record.credentialId, `${pathArg}.credentialId`),
|
|
542
|
+
credentialVersion: requireSafeInteger(
|
|
543
|
+
record.credentialVersion,
|
|
544
|
+
`${pathArg}.credentialVersion`,
|
|
545
|
+
1,
|
|
546
|
+
),
|
|
547
|
+
credentialSecretKey,
|
|
548
|
+
};
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
export const normalizeCoreMailDesiredState = (
|
|
552
|
+
valueArg: unknown,
|
|
553
|
+
): ICoreMailDesiredState => {
|
|
554
|
+
const record = readRecord(valueArg, 'desiredState');
|
|
555
|
+
assertKeys(record, 'desiredState', ['schemaVersion', 'configEpoch', 'bindings', 'gateway']);
|
|
556
|
+
if (record.schemaVersion !== 1) {
|
|
557
|
+
fail('desiredState.schemaVersion must be 1');
|
|
558
|
+
}
|
|
559
|
+
const bindings = readArray(
|
|
560
|
+
record.bindings,
|
|
561
|
+
'desiredState.bindings',
|
|
562
|
+
coreMailContractLimits.maximumBindings,
|
|
563
|
+
).map((entryArg, indexArg) =>
|
|
564
|
+
normalizeBinding(entryArg, `desiredState.bindings[${indexArg}]`),
|
|
565
|
+
);
|
|
566
|
+
const bindingIds = new Set<string>();
|
|
567
|
+
const activeInboundRecipients = new Set<string>();
|
|
568
|
+
for (const binding of bindings) {
|
|
569
|
+
if (bindingIds.has(binding.bindingId)) {
|
|
570
|
+
fail('desiredState.bindings contains a duplicate bindingId');
|
|
571
|
+
}
|
|
572
|
+
bindingIds.add(binding.bindingId);
|
|
573
|
+
if (binding.state === 'active') {
|
|
574
|
+
for (const recipient of binding.inboundRecipients) {
|
|
575
|
+
if (activeInboundRecipients.has(recipient)) {
|
|
576
|
+
fail('active inbound recipient ownership must be unique');
|
|
577
|
+
}
|
|
578
|
+
activeInboundRecipients.add(recipient);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
bindings.sort((leftArg, rightArg) =>
|
|
583
|
+
compareStrings(leftArg.bindingId, rightArg.bindingId),
|
|
584
|
+
);
|
|
585
|
+
return deepFreezeValue({
|
|
586
|
+
schemaVersion: 1,
|
|
587
|
+
configEpoch: requireSafeInteger(record.configEpoch, 'desiredState.configEpoch', 1),
|
|
588
|
+
bindings,
|
|
589
|
+
gateway: normalizeGateway(record.gateway, 'desiredState.gateway'),
|
|
590
|
+
});
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
export const canonicalizeCoreMailDesiredState = (
|
|
594
|
+
valueArg: unknown,
|
|
595
|
+
): string => {
|
|
596
|
+
return canonicalizeStrictJson(
|
|
597
|
+
normalizeCoreMailDesiredState(valueArg),
|
|
598
|
+
fail,
|
|
599
|
+
'CoreMail desired state',
|
|
600
|
+
);
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
export const createCoreMailDesiredStateDigest = async (
|
|
604
|
+
valueArg: unknown,
|
|
605
|
+
): Promise<TCoreMailSha256> => {
|
|
606
|
+
const digestHex = await createCanonicalJsonSha256Hex(
|
|
607
|
+
canonicalizeCoreMailDesiredState(valueArg),
|
|
608
|
+
fail,
|
|
609
|
+
);
|
|
610
|
+
return `sha256:${digestHex}` as TCoreMailSha256;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
export const verifyCoreMailDesiredStateDigest = async (
|
|
614
|
+
valueArg: unknown,
|
|
615
|
+
digestArg: unknown,
|
|
616
|
+
): Promise<boolean> => {
|
|
617
|
+
return normalizeCoreMailSha256(digestArg, 'desiredStateDigest')
|
|
618
|
+
=== await createCoreMailDesiredStateDigest(valueArg);
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
export const normalizeCoreMailControlBootstrap = (
|
|
622
|
+
valueArg: unknown,
|
|
623
|
+
): ICoreMailControlBootstrap => {
|
|
624
|
+
const record = readRecord(valueArg, 'controlBootstrap');
|
|
625
|
+
assertKeys(
|
|
626
|
+
record,
|
|
627
|
+
'controlBootstrap',
|
|
628
|
+
['schemaVersion', 'coreMailServiceId', 'credentials'],
|
|
629
|
+
);
|
|
630
|
+
if (record.schemaVersion !== 1) {
|
|
631
|
+
fail('controlBootstrap.schemaVersion must be 1');
|
|
632
|
+
}
|
|
633
|
+
return deepFreezeValue({
|
|
634
|
+
schemaVersion: 1,
|
|
635
|
+
coreMailServiceId: requireIdentifier(
|
|
636
|
+
record.coreMailServiceId,
|
|
637
|
+
'controlBootstrap.coreMailServiceId',
|
|
638
|
+
),
|
|
639
|
+
credentials: normalizeCredentialSet(
|
|
640
|
+
record.credentials,
|
|
641
|
+
'controlBootstrap.credentials',
|
|
642
|
+
),
|
|
643
|
+
});
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
export const normalizeCoreMailGatewayPeerDesiredState = (
|
|
647
|
+
valueArg: unknown,
|
|
648
|
+
): ICoreMailGatewayPeerDesiredState => {
|
|
649
|
+
const record = readRecord(valueArg, 'gatewayPeer');
|
|
650
|
+
assertKeys(
|
|
651
|
+
record,
|
|
652
|
+
'gatewayPeer',
|
|
653
|
+
['schemaVersion', 'coreMailServiceId', 'transferOrigin', 'credentials'],
|
|
654
|
+
);
|
|
655
|
+
if (record.schemaVersion !== 1) {
|
|
656
|
+
fail('gatewayPeer.schemaVersion must be 1');
|
|
657
|
+
}
|
|
658
|
+
return deepFreezeValue({
|
|
659
|
+
schemaVersion: 1,
|
|
660
|
+
coreMailServiceId: requireIdentifier(
|
|
661
|
+
record.coreMailServiceId,
|
|
662
|
+
'gatewayPeer.coreMailServiceId',
|
|
663
|
+
),
|
|
664
|
+
transferOrigin: requireCanonicalUrl(
|
|
665
|
+
record.transferOrigin,
|
|
666
|
+
'gatewayPeer.transferOrigin',
|
|
667
|
+
'https:',
|
|
668
|
+
true,
|
|
669
|
+
),
|
|
670
|
+
credentials: normalizeCredentialSet(
|
|
671
|
+
record.credentials,
|
|
672
|
+
'gatewayPeer.credentials',
|
|
673
|
+
),
|
|
674
|
+
});
|
|
675
|
+
};
|