@serve.zone/interfaces 31.2.0 → 31.4.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 (38) hide show
  1. package/changelog.md +74 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/cluster.d.ts +52 -0
  4. package/dist_ts/data/cluster.js +68 -1
  5. package/dist_ts/data/clusteringress.d.ts +137 -0
  6. package/dist_ts/data/clusteringress.js +302 -0
  7. package/dist_ts/data/coremail.runtime.js +4 -8
  8. package/dist_ts/data/index.d.ts +1 -0
  9. package/dist_ts/data/index.js +2 -1
  10. package/dist_ts/data/networkprotection.d.ts +18 -0
  11. package/dist_ts/data/networkprotection.js +24 -3
  12. package/dist_ts/data/privatenetwork.js +3 -5
  13. package/dist_ts/private/ipv4.d.ts +9 -0
  14. package/dist_ts/private/ipv4.js +23 -0
  15. package/dist_ts/private/networkcontract.d.ts +1 -1
  16. package/dist_ts/private/networkcontract.js +7 -3
  17. package/dist_ts/private/utccalendarday.d.ts +17 -0
  18. package/dist_ts/private/utccalendarday.js +27 -0
  19. package/dist_ts/requests/cluster.d.ts +47 -0
  20. package/dist_ts/requests/clusteringress.d.ts +120 -0
  21. package/dist_ts/requests/clusteringress.js +3 -0
  22. package/dist_ts/requests/index.d.ts +2 -1
  23. package/dist_ts/requests/index.js +3 -2
  24. package/package.json +1 -1
  25. package/readme.md +126 -0
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/data/cluster.ts +126 -0
  28. package/ts/data/clusteringress.ts +425 -0
  29. package/ts/data/coremail.runtime.ts +3 -10
  30. package/ts/data/index.ts +1 -0
  31. package/ts/data/networkprotection.ts +34 -2
  32. package/ts/data/privatenetwork.ts +3 -4
  33. package/ts/private/ipv4.ts +24 -0
  34. package/ts/private/networkcontract.ts +8 -2
  35. package/ts/private/utccalendarday.ts +30 -0
  36. package/ts/requests/cluster.ts +59 -0
  37. package/ts/requests/clusteringress.ts +140 -0
  38. package/ts/requests/index.ts +2 -0
@@ -0,0 +1,425 @@
1
+ import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
2
+ import { isUtcCalendarDay } from '../private/utccalendarday.js';
3
+
4
+ /**
5
+ * The cluster ingress contracts.
6
+ *
7
+ * A cluster's ingress terminates TLS for the hostnames its services publish and
8
+ * forwards to the host ports Pallet published for the workloads behind them.
9
+ * Cloudly computes the table — it is the only party that knows every service,
10
+ * every ready endpoint and every certificate — and the relay carries it to the
11
+ * ingress and the ingress's counters back.
12
+ *
13
+ * Names here carry no version suffix and no schema field: the protocol version
14
+ * is the installed `@serve.zone/interfaces` version.
15
+ */
16
+
17
+ export const clusterIngressLimits = Object.freeze({
18
+ minimumIngressBearerLength: 16,
19
+ maximumIngressBearerLength: 4096,
20
+ maximumHostNameLength: 253,
21
+ maximumRoutes: 4096,
22
+ maximumPortRoutes: 1024,
23
+ maximumDestinationsPerRoute: 64,
24
+ maximumStatisticsPageSize: 1000,
25
+ maximumCursorLength: 512,
26
+ maximumIdentifierLength: 200,
27
+ maximumIngressCapabilities: 32,
28
+ });
29
+
30
+ /** One backend a route forwards to: an address a node answers on, and its port. */
31
+ export interface IClusterRouteDestination {
32
+ address: string;
33
+ port: number;
34
+ }
35
+
36
+ /** Basic authentication for a route, as the proxy has always expressed it. */
37
+ export interface IClusterRouteAuthentication {
38
+ type: 'Basic';
39
+ user: string;
40
+ pass: string;
41
+ }
42
+
43
+ /**
44
+ * One hostname the ingress serves.
45
+ *
46
+ * `certificate` carries the private key the ingress needs to terminate TLS for
47
+ * `hostName`. A route table is therefore secret-bearing end to end: it is never
48
+ * logged, never journaled, never written to disk, and no validation reason
49
+ * derived from it ever quotes it.
50
+ */
51
+ export interface IClusterHttpRoute {
52
+ hostName: string;
53
+ destinations: IClusterRouteDestination[];
54
+ certificate: {
55
+ publicKey: string;
56
+ privateKey: string;
57
+ };
58
+ authentication?: IClusterRouteAuthentication;
59
+ }
60
+
61
+ /** One TCP or UDP port the ingress forwards without terminating anything. */
62
+ export interface IClusterPortRoute {
63
+ name: string;
64
+ listenPort: number;
65
+ transport: 'tcp' | 'udp';
66
+ destinations: IClusterRouteDestination[];
67
+ }
68
+
69
+ /**
70
+ * Everything a cluster's ingress serves, as one table.
71
+ *
72
+ * `revision` is the only idempotency the feed has: an ingress and a relay apply
73
+ * a table whose revision is greater than the one they hold and ignore anything
74
+ * else, so a repeated push, a replayed push and a push that crossed a newer one
75
+ * all settle the same way.
76
+ */
77
+ export interface IClusterRouteTable {
78
+ revision: number;
79
+ issuedAt: number;
80
+ routes: IClusterHttpRoute[];
81
+ portRoutes: IClusterPortRoute[];
82
+ }
83
+
84
+ /**
85
+ * One day of one hostname's traffic on one node, as the ingress counted it.
86
+ *
87
+ * Day buckets rather than rates: a rate answers "how busy is it now", which the
88
+ * live proxy already answers, while a bucket survives a restart, a poll that
89
+ * straddles midnight and a Cloudly outage.
90
+ */
91
+ export interface IClusterTrafficStatistics {
92
+ serviceId: string;
93
+ hostName: string;
94
+ nodeId: string;
95
+ /** UTC calendar day, `YYYY-MM-DD`. */
96
+ dayUtc: string;
97
+ requests: number;
98
+ bytesIn: number;
99
+ bytesOut: number;
100
+ connections: number;
101
+ /**
102
+ * What the proxy could observe about its backends. HTTP status classes are
103
+ * deliberately absent: the proxy exposes no status counters today, and a
104
+ * number this contract cannot source would be a guess.
105
+ */
106
+ backendErrors: {
107
+ connect: number;
108
+ handshake: number;
109
+ request: number;
110
+ };
111
+ updatedAt: number;
112
+ }
113
+
114
+ const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
115
+ Boolean(valueArg)
116
+ && typeof valueArg === 'object'
117
+ && !Array.isArray(valueArg)
118
+ && (Object.getPrototypeOf(valueArg) === Object.prototype
119
+ || Object.getPrototypeOf(valueArg) === null)
120
+ );
121
+
122
+ const hasExactKeys = (
123
+ valueArg: Record<string, unknown>,
124
+ keysArg: readonly string[],
125
+ optionalKeysArg: readonly string[] = [],
126
+ ): boolean => {
127
+ const actualKeys = Object.keys(valueArg);
128
+ return keysArg.every((keyArg) => actualKeys.includes(keyArg))
129
+ && actualKeys.every((keyArg) => keysArg.includes(keyArg) || optionalKeysArg.includes(keyArg));
130
+ };
131
+
132
+ const isCounter = (valueArg: unknown): valueArg is number => (
133
+ Number.isSafeInteger(valueArg) && (valueArg as number) >= 0
134
+ );
135
+
136
+ const isPort = (valueArg: unknown): valueArg is number => (
137
+ Number.isSafeInteger(valueArg) && (valueArg as number) >= 1 && (valueArg as number) <= 65_535
138
+ );
139
+
140
+ const hostLabelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
141
+ // Built from the limit rather than beside it, so the two can never disagree.
142
+ const identifierPattern = new RegExp(
143
+ `^[A-Za-z0-9][A-Za-z0-9:._-]{0,${clusterIngressLimits.maximumIdentifierLength - 1}}$`,
144
+ );
145
+
146
+ /**
147
+ * A hostname a certificate can be issued for and a browser can present.
148
+ *
149
+ * Lowercase labels only, because a certificate names the lowercase form and a
150
+ * table that carried both would serve one of them without a certificate.
151
+ */
152
+ export const isClusterIngressHostName = (valueArg: unknown): valueArg is string => {
153
+ if (typeof valueArg !== 'string'
154
+ || valueArg.length === 0
155
+ || valueArg.length > clusterIngressLimits.maximumHostNameLength) {
156
+ return false;
157
+ }
158
+ const labels = valueArg.split('.');
159
+ return labels.length >= 2 && labels.every((labelArg) => hostLabelPattern.test(labelArg));
160
+ };
161
+
162
+ const validateDestinations = (valueArg: unknown, pathArg: string): string[] => {
163
+ if (!Array.isArray(valueArg)
164
+ || valueArg.length === 0
165
+ || valueArg.length > clusterIngressLimits.maximumDestinationsPerRoute) {
166
+ return [`${pathArg} must list 1 to `
167
+ + `${clusterIngressLimits.maximumDestinationsPerRoute} destinations`];
168
+ }
169
+ const errors: string[] = [];
170
+ for (const [index, destination] of valueArg.entries()) {
171
+ const destinationPath = `${pathArg}[${index}]`;
172
+ if (!isRecord(destination) || !hasExactKeys(destination, ['address', 'port'])) {
173
+ errors.push(`${destinationPath} must use its exact schema`);
174
+ continue;
175
+ }
176
+ if (!isPlausibleUnicastIpv4(destination.address)) {
177
+ errors.push(`${destinationPath} address must be a unicast IPv4 literal`);
178
+ }
179
+ if (!isPort(destination.port)) {
180
+ errors.push(`${destinationPath} port must be an integer from 1 to 65535`);
181
+ }
182
+ }
183
+ return errors;
184
+ };
185
+
186
+ const validateCertificate = (valueArg: unknown, pathArg: string): string[] => {
187
+ // Presence and shape only. The material itself is never described in a reason:
188
+ // a validation message travels into logs, and this one would carry a key.
189
+ if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['publicKey', 'privateKey'])) {
190
+ return [`${pathArg} must use its exact schema`];
191
+ }
192
+ const errors: string[] = [];
193
+ if (typeof valueArg.publicKey !== 'string' || valueArg.publicKey.length === 0) {
194
+ errors.push(`${pathArg} publicKey must be a non-empty PEM string`);
195
+ }
196
+ if (typeof valueArg.privateKey !== 'string' || valueArg.privateKey.length === 0) {
197
+ errors.push(`${pathArg} privateKey must be a non-empty PEM string`);
198
+ }
199
+ return errors;
200
+ };
201
+
202
+ const validateAuthentication = (valueArg: unknown, pathArg: string): string[] => {
203
+ if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['type', 'user', 'pass'])) {
204
+ return [`${pathArg} must use its exact schema`];
205
+ }
206
+ return valueArg.type === 'Basic'
207
+ && typeof valueArg.user === 'string' && valueArg.user.length > 0
208
+ && typeof valueArg.pass === 'string' && valueArg.pass.length > 0
209
+ ? []
210
+ : [`${pathArg} must be Basic authentication with a user and a pass`];
211
+ };
212
+
213
+ const validateHttpRoute = (valueArg: unknown, pathArg: string): string[] => {
214
+ if (!isRecord(valueArg)
215
+ || !hasExactKeys(valueArg, ['hostName', 'destinations', 'certificate'], ['authentication'])) {
216
+ return [`${pathArg} must use its exact schema`];
217
+ }
218
+ const errors: string[] = [];
219
+ if (!isClusterIngressHostName(valueArg.hostName)) {
220
+ errors.push(`${pathArg} hostName must be a lowercase dotted hostname a certificate can name`);
221
+ }
222
+ errors.push(...validateDestinations(valueArg.destinations, `${pathArg} destinations`));
223
+ errors.push(...validateCertificate(valueArg.certificate, `${pathArg} certificate`));
224
+ if (valueArg.authentication !== undefined) {
225
+ errors.push(...validateAuthentication(valueArg.authentication, `${pathArg} authentication`));
226
+ }
227
+ return errors;
228
+ };
229
+
230
+ const validatePortRoute = (valueArg: unknown, pathArg: string): string[] => {
231
+ if (!isRecord(valueArg)
232
+ || !hasExactKeys(valueArg, ['name', 'listenPort', 'transport', 'destinations'])) {
233
+ return [`${pathArg} must use its exact schema`];
234
+ }
235
+ const errors: string[] = [];
236
+ if (typeof valueArg.name !== 'string' || !identifierPattern.test(valueArg.name)) {
237
+ errors.push(`${pathArg} name must be a bounded identifier`);
238
+ }
239
+ if (!isPort(valueArg.listenPort)) {
240
+ errors.push(`${pathArg} listenPort must be an integer from 1 to 65535`);
241
+ }
242
+ if (valueArg.transport !== 'tcp' && valueArg.transport !== 'udp') {
243
+ errors.push(`${pathArg} transport must be tcp or udp`);
244
+ }
245
+ errors.push(...validateDestinations(valueArg.destinations, `${pathArg} destinations`));
246
+ return errors;
247
+ };
248
+
249
+ /**
250
+ * Validates one route table and names every reason it is refused.
251
+ *
252
+ * Shape only. Whether a hostname belongs to the cluster, whether a certificate
253
+ * matches it, and whether a destination is a node of this cluster are decisions
254
+ * for the party that computed the table; a table that passes here has claimed
255
+ * none of them. No reason ever quotes certificate material.
256
+ */
257
+ export const validateClusterRouteTable = (
258
+ valueArg: unknown,
259
+ pathArg = 'cluster route table',
260
+ ): string[] => {
261
+ if (!isRecord(valueArg)
262
+ || !hasExactKeys(valueArg, ['revision', 'issuedAt', 'routes', 'portRoutes'])) {
263
+ return [`${pathArg} must use its exact schema`];
264
+ }
265
+ const errors: string[] = [];
266
+ if (!isCounter(valueArg.revision)) {
267
+ errors.push(`${pathArg} revision must be a non-negative safe integer`);
268
+ }
269
+ if (!Number.isSafeInteger(valueArg.issuedAt) || (valueArg.issuedAt as number) <= 0) {
270
+ errors.push(`${pathArg} issuedAt must be a positive epoch millisecond timestamp`);
271
+ }
272
+ if (!Array.isArray(valueArg.routes)
273
+ || valueArg.routes.length > clusterIngressLimits.maximumRoutes) {
274
+ errors.push(`${pathArg} routes must list at most ${clusterIngressLimits.maximumRoutes} routes`);
275
+ } else {
276
+ const seenHostNames = new Set<string>();
277
+ for (const [index, route] of valueArg.routes.entries()) {
278
+ errors.push(...validateHttpRoute(route, `${pathArg} routes[${index}]`));
279
+ const hostName = isRecord(route) ? route.hostName : undefined;
280
+ if (typeof hostName === 'string') {
281
+ if (seenHostNames.has(hostName)) {
282
+ errors.push(`${pathArg} routes[${index}] repeats hostName ${hostName}`);
283
+ }
284
+ seenHostNames.add(hostName);
285
+ }
286
+ }
287
+ }
288
+ if (!Array.isArray(valueArg.portRoutes)
289
+ || valueArg.portRoutes.length > clusterIngressLimits.maximumPortRoutes) {
290
+ errors.push(`${pathArg} portRoutes must list at most `
291
+ + `${clusterIngressLimits.maximumPortRoutes} port routes`);
292
+ } else {
293
+ const seenPorts = new Set<string>();
294
+ for (const [index, portRoute] of valueArg.portRoutes.entries()) {
295
+ errors.push(...validatePortRoute(portRoute, `${pathArg} portRoutes[${index}]`));
296
+ if (!isRecord(portRoute)) {
297
+ continue;
298
+ }
299
+ const key = `${String(portRoute.transport)}:${String(portRoute.listenPort)}`;
300
+ if (seenPorts.has(key)) {
301
+ errors.push(`${pathArg} portRoutes[${index}] repeats ${key}`);
302
+ }
303
+ seenPorts.add(key);
304
+ }
305
+ }
306
+ return errors;
307
+ };
308
+
309
+ /** Validates one day bucket of ingress counters. */
310
+ export const validateClusterTrafficStatistics = (
311
+ valueArg: unknown,
312
+ pathArg = 'cluster traffic statistics',
313
+ ): string[] => {
314
+ if (!isRecord(valueArg) || !hasExactKeys(valueArg, [
315
+ 'serviceId', 'hostName', 'nodeId', 'dayUtc', 'requests', 'bytesIn', 'bytesOut',
316
+ 'connections', 'backendErrors', 'updatedAt',
317
+ ])) {
318
+ return [`${pathArg} must use its exact schema`];
319
+ }
320
+ const errors: string[] = [];
321
+ for (const key of ['serviceId', 'nodeId'] as const) {
322
+ const value = valueArg[key];
323
+ if (typeof value !== 'string' || !identifierPattern.test(value)) {
324
+ errors.push(`${pathArg} ${key} must be a bounded identifier`);
325
+ }
326
+ }
327
+ if (!isClusterIngressHostName(valueArg.hostName)) {
328
+ errors.push(`${pathArg} hostName must be a lowercase dotted hostname`);
329
+ }
330
+ if (!isUtcCalendarDay(valueArg.dayUtc)) {
331
+ errors.push(`${pathArg} dayUtc must be a real YYYY-MM-DD UTC calendar day`);
332
+ }
333
+ for (const key of ['requests', 'bytesIn', 'bytesOut', 'connections'] as const) {
334
+ if (!isCounter(valueArg[key])) {
335
+ errors.push(`${pathArg} ${key} must be a non-negative safe integer`);
336
+ }
337
+ }
338
+ const backendErrors = valueArg.backendErrors;
339
+ if (!isRecord(backendErrors)
340
+ || !hasExactKeys(backendErrors, ['connect', 'handshake', 'request'])
341
+ || !isCounter(backendErrors.connect)
342
+ || !isCounter(backendErrors.handshake)
343
+ || !isCounter(backendErrors.request)) {
344
+ errors.push(`${pathArg} backendErrors must count connect, handshake and request failures`);
345
+ }
346
+ if (!Number.isSafeInteger(valueArg.updatedAt) || (valueArg.updatedAt as number) <= 0) {
347
+ errors.push(`${pathArg} updatedAt must be a positive epoch millisecond timestamp`);
348
+ }
349
+ return errors;
350
+ };
351
+
352
+ /**
353
+ * Validates one page of ingress counters, as a reader receives it.
354
+ *
355
+ * The cursor is opaque to everyone but the ingress that issued it: bounded and
356
+ * printable here, meaningful only there.
357
+ */
358
+ export const validateClusterTrafficStatisticsPage = (
359
+ valueArg: unknown,
360
+ pathArg = 'cluster traffic statistics page',
361
+ ): string[] => {
362
+ if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['statistics'], ['nextCursor'])) {
363
+ return [`${pathArg} must use its exact schema`];
364
+ }
365
+ const errors: string[] = [];
366
+ if (!Array.isArray(valueArg.statistics)
367
+ || valueArg.statistics.length > clusterIngressLimits.maximumStatisticsPageSize) {
368
+ errors.push(`${pathArg} statistics must list at most `
369
+ + `${clusterIngressLimits.maximumStatisticsPageSize} entries`);
370
+ } else {
371
+ for (const [index, item] of valueArg.statistics.entries()) {
372
+ errors.push(...validateClusterTrafficStatistics(item, `${pathArg} statistics[${index}]`));
373
+ }
374
+ }
375
+ if (valueArg.nextCursor !== undefined
376
+ && (typeof valueArg.nextCursor !== 'string'
377
+ || valueArg.nextCursor.length === 0
378
+ || valueArg.nextCursor.length > clusterIngressLimits.maximumCursorLength)) {
379
+ errors.push(`${pathArg} nextCursor must be 1 to `
380
+ + `${clusterIngressLimits.maximumCursorLength} characters when present`);
381
+ }
382
+ return errors;
383
+ };
384
+
385
+ /**
386
+ * Validates one cluster ingress registration and names every reason it is refused.
387
+ *
388
+ * Shape only, and deliberately so: whether the bearer is the one Cloudly minted
389
+ * for this cluster's ingress is Cloudly's to verify against its own records, and
390
+ * which cluster is speaking comes from the identity tag on the relay's session.
391
+ * A registration that passes here has proven nothing — and no reason it returns
392
+ * ever quotes the bearer, because a reason travels into logs.
393
+ */
394
+ export const validateClusterIngressRegistration = (
395
+ valueArg: unknown,
396
+ pathArg = 'cluster ingress registration',
397
+ ): string[] => {
398
+ if (!isRecord(valueArg)
399
+ || !hasExactKeys(valueArg, ['bearer', 'version', 'nodeId'], ['capabilities'])) {
400
+ return [`${pathArg} must use its exact schema`];
401
+ }
402
+ const errors: string[] = [];
403
+ if (typeof valueArg.bearer !== 'string'
404
+ || valueArg.bearer.length < clusterIngressLimits.minimumIngressBearerLength
405
+ || valueArg.bearer.length > clusterIngressLimits.maximumIngressBearerLength) {
406
+ errors.push(`${pathArg} bearer must be ${clusterIngressLimits.minimumIngressBearerLength} to `
407
+ + `${clusterIngressLimits.maximumIngressBearerLength} characters`);
408
+ }
409
+ if (typeof valueArg.version !== 'string' || !identifierPattern.test(valueArg.version)) {
410
+ errors.push(`${pathArg} version must be a bounded identifier`);
411
+ }
412
+ if (typeof valueArg.nodeId !== 'string' || !identifierPattern.test(valueArg.nodeId)) {
413
+ errors.push(`${pathArg} nodeId must be a bounded identifier`);
414
+ }
415
+ if (valueArg.capabilities !== undefined
416
+ && (!Array.isArray(valueArg.capabilities)
417
+ || valueArg.capabilities.length > clusterIngressLimits.maximumIngressCapabilities
418
+ || !valueArg.capabilities.every((capabilityArg) => (
419
+ typeof capabilityArg === 'string' && identifierPattern.test(capabilityArg)
420
+ )))) {
421
+ errors.push(`${pathArg} capabilities must be at most `
422
+ + `${clusterIngressLimits.maximumIngressCapabilities} bounded identifiers when present`);
423
+ }
424
+ return errors;
425
+ };
@@ -1,3 +1,4 @@
1
+ import { isRealUtcCalendarDay, utcCalendarDayPattern } from '../private/utccalendarday.js';
1
2
  import {
2
3
  canonicalizeStrictJson,
3
4
  createCanonicalJsonSha256Hex,
@@ -2047,20 +2048,12 @@ export const normalizeCoreMailGatewayPeerDesiredState = (
2047
2048
  });
2048
2049
  };
2049
2050
 
2050
- const utcCalendarDayRegex = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
2051
-
2052
2051
  const requireUtcCalendarDay = (valueArg: unknown, pathArg: string): string => {
2053
2052
  const value = requireString(valueArg, pathArg, 10);
2054
- if (!utcCalendarDayRegex.test(value)) {
2053
+ if (!utcCalendarDayPattern.test(value)) {
2055
2054
  fail(`${pathArg} must be a canonical YYYY-MM-DD UTC day`);
2056
2055
  }
2057
- const [year, month, day] = value.split('-').map(Number);
2058
- const parsed = new Date(Date.UTC(year, month - 1, day));
2059
- if (
2060
- parsed.getUTCFullYear() !== year
2061
- || parsed.getUTCMonth() + 1 !== month
2062
- || parsed.getUTCDate() !== day
2063
- ) {
2056
+ if (!isRealUtcCalendarDay(value)) {
2064
2057
  fail(`${pathArg} must be a real UTC calendar day`);
2065
2058
  }
2066
2059
  return value;
package/ts/data/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './cloudlyconfig.js';
2
2
  export * from './cluster.js';
3
+ export * from './clusteringress.js';
3
4
  export * from './config.js';
4
5
  export * from './coremail.js';
5
6
  export * from './coremail.runtime.js';
@@ -1,3 +1,4 @@
1
+ import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
1
2
  import type { TSha256Digest } from './immutableimage.js';
2
3
  import type { IRuntimeAuthorityReferenceV1 } from './runtimeassignment.js';
3
4
  import type { IRuntimeNetworkNodeScopeV1 } from './networkprojection.js';
@@ -32,6 +33,16 @@ export interface IRuntimeNetworkProtectionReceiptV1 extends IRuntimeNetworkNodeS
32
33
  export interface IReportRuntimeNetworkProtectionRequestV1 {
33
34
  session: IRuntimeSessionBindingV1;
34
35
  receipt: IRuntimeNetworkProtectionReceiptV1;
36
+ /**
37
+ * The address this node answers on, as the node itself observed it.
38
+ *
39
+ * A route to one of this node's published host ports uses exactly this
40
+ * address. Only the node can prove it — it holds the uplink observation the
41
+ * address belongs to — so it states it here, on the report it already sends
42
+ * about its own network, and Cloudly stores it rather than inferring one.
43
+ * Absent from a node that has not stated one; never defaulted, never guessed.
44
+ */
45
+ uplinkAddress?: string;
35
46
  }
36
47
  export interface IReportRuntimeNetworkProtectionResponseV1 {
37
48
  status: 'accepted' | 'replay';
@@ -113,10 +124,31 @@ export const admitRuntimeNetworkProtectionReceiptV1 = async (
113
124
  };
114
125
 
115
126
  export const snapshotReportRuntimeNetworkProtectionRequestV1 = (value: unknown): IReportRuntimeNetworkProtectionRequestV1 => networkSafe(() => {
116
- const item = networkRecord(captureNetwork(value), ['session', 'receipt']);
117
- return { session: snapshotRuntimeSessionBindingV1(item.session), receipt: snapshotRuntimeNetworkProtectionReceiptV1(item.receipt) };
127
+ const item = networkRecord(captureNetwork(value), ['session', 'receipt'], ['uplinkAddress']);
128
+ const request: IReportRuntimeNetworkProtectionRequestV1 = {
129
+ session: snapshotRuntimeSessionBindingV1(item.session),
130
+ receipt: snapshotRuntimeNetworkProtectionReceiptV1(item.receipt),
131
+ };
132
+ if (Object.hasOwn(item, 'uplinkAddress')) {
133
+ request.uplinkAddress = runtimeNodeUplinkAddress(item.uplinkAddress);
134
+ }
135
+ return request;
118
136
  });
119
137
 
138
+ /**
139
+ * The address a route to this node's published ports uses.
140
+ *
141
+ * A unicast IPv4 literal that is neither loopback nor link-local: a node that
142
+ * stated one of those would publish an address no other node can reach, and a
143
+ * route table built from it would send every request nowhere.
144
+ */
145
+ export const runtimeNodeUplinkAddress = (value: unknown): string => {
146
+ requireNetwork(isPlausibleUnicastIpv4(value)
147
+ && !value.startsWith('127.')
148
+ && !value.startsWith('169.254.'));
149
+ return value;
150
+ };
151
+
120
152
  /** Both trusted bindings come from the receiver's fenced physical-session/history owner. */
121
153
  export const bindReportRuntimeNetworkProtectionRequestV1 = (
122
154
  value: unknown, trustedCurrentSessionValue: unknown, trustedReporterSessionValue: unknown,
@@ -1,3 +1,5 @@
1
+ import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
2
+
1
3
  /** Cloudly owns these definitions. Membership never follows node placement. */
2
4
  export interface IPrivateNetworkPolicyV1 {
3
5
  connectivity: 'member-full-mesh';
@@ -133,10 +135,7 @@ const sorted = (values: string[]): string[] => {
133
135
  return values;
134
136
  };
135
137
  const ipv4 = (value: unknown): string => {
136
- requireValue(typeof value === 'string');
137
- const parts = value.split('.');
138
- requireValue(parts.length === 4 && parts.every(part => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255));
139
- requireValue(Number(parts[0]) > 0 && Number(parts[0]) < 224 && value !== '255.255.255.255');
138
+ requireValue(isPlausibleUnicastIpv4(value));
140
139
  return value;
141
140
  };
142
141
  const publicSuffix = (value: unknown): string => {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The dotted-quad IPv4 form every consumer of this package agrees on.
3
+ *
4
+ * Shape only: an address that passes here is still neither routable, owned, reachable nor
5
+ * authorized. The rule refuses what can never be a unicast host address — a quad that is not
6
+ * canonical, the `0.0.0.0/8` unspecified block, and everything from `224.0.0.0` up, which is
7
+ * multicast and reserved space and therefore also covers the all-ones broadcast address.
8
+ */
9
+ export const isPlausibleUnicastIpv4 = (valueArg: unknown): valueArg is string => {
10
+ if (typeof valueArg !== 'string') {
11
+ return false;
12
+ }
13
+ const octets = valueArg.split('.');
14
+ if (octets.length !== 4) {
15
+ return false;
16
+ }
17
+ if (!octets.every((octetArg) => (
18
+ /^(?:0|[1-9][0-9]{0,2})$/.test(octetArg) && Number(octetArg) <= 255
19
+ ))) {
20
+ return false;
21
+ }
22
+ const leadingOctet = Number(octets[0]);
23
+ return leadingOctet > 0 && leadingOctet < 224;
24
+ };
@@ -56,10 +56,16 @@ export const captureNetwork = (value: unknown): unknown => networkSafe(() => {
56
56
  return result;
57
57
  });
58
58
 
59
- export const networkRecord = (value: unknown, keys: string[]): Record<string, unknown> => {
59
+ export const networkRecord = (
60
+ value: unknown, keys: string[], optionalKeys: string[] = [],
61
+ ): Record<string, unknown> => {
60
62
  requireNetwork(value !== null && typeof value === 'object' && !Array.isArray(value));
61
63
  const actual = Object.keys(value);
62
- requireNetwork(actual.length === keys.length && keys.every(key => Object.hasOwn(value, key)));
64
+ // Absence stays absent: a stated optional key is read, an omitted one is not
65
+ // defaulted, so a receiver can tell a sender that stated nothing from one that
66
+ // stated a value.
67
+ requireNetwork(keys.every(key => Object.hasOwn(value, key))
68
+ && actual.every(key => keys.includes(key) || optionalKeys.includes(key)));
63
69
  return value as Record<string, unknown>;
64
70
  };
65
71
  export const networkList = <T>(value: unknown, maximum: number, read: (item: unknown) => T): T[] => {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The UTC calendar day every consumer of this package agrees on.
3
+ *
4
+ * Two facts, deliberately separate: whether a value has the canonical shape, and whether that
5
+ * shape names a day that exists. `2026-13-45` passes the first and fails the second, and a
6
+ * caller that only checked the pattern would store a day no clock can ever produce.
7
+ */
8
+ export const utcCalendarDayPattern = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
9
+
10
+ /**
11
+ * Whether a canonical `YYYY-MM-DD` string names a real day.
12
+ *
13
+ * Round-trips through `Date.UTC`, which normalises an impossible day into a neighbouring one:
14
+ * a value that survives the round trip unchanged is the day it claims to be.
15
+ */
16
+ export const isRealUtcCalendarDay = (valueArg: string): boolean => {
17
+ if (!utcCalendarDayPattern.test(valueArg)) {
18
+ return false;
19
+ }
20
+ const [year, month, day] = valueArg.split('-').map(Number);
21
+ const parsed = new Date(Date.UTC(year, month - 1, day));
22
+ return parsed.getUTCFullYear() === year
23
+ && parsed.getUTCMonth() + 1 === month
24
+ && parsed.getUTCDate() === day;
25
+ };
26
+
27
+ /** Both facts at once, for a caller that reports one reason. */
28
+ export const isUtcCalendarDay = (valueArg: unknown): valueArg is string => (
29
+ typeof valueArg === 'string' && isRealUtcCalendarDay(valueArg)
30
+ );
@@ -66,6 +66,65 @@ export interface IReq_Any_Cloudly_UpdateCluster extends plugins.typedrequestInte
66
66
  };
67
67
  }
68
68
 
69
+ /**
70
+ * Enables this cluster's relay.
71
+ *
72
+ * Cloudly derives the relay origin itself from the cluster and its own public hostname, so a
73
+ * caller cannot name one, and refuses the request when no managed DNS zone matches that name.
74
+ * The response carries the block Cloudly published, which is the same block every cluster-side
75
+ * component then reads from `ICluster.data.relay`.
76
+ */
77
+ export interface IReq_Any_Cloudly_EnableClusterRelayV1 extends plugins.typedrequestInterfaces.implementsTR<
78
+ plugins.typedrequestInterfaces.ITypedRequest,
79
+ IReq_Any_Cloudly_EnableClusterRelayV1
80
+ > {
81
+ method: 'enableClusterRelayV1';
82
+ request: {
83
+ identity: userInterfaces.IIdentity;
84
+ clusterId: string;
85
+ };
86
+ response: {
87
+ relay: clusterInterfaces.IClusterRelayV1;
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Disables this cluster's relay, removing the block from the cluster document.
93
+ *
94
+ * The nodes of the cluster target Cloudly directly again, so `relay` is explicitly `null` in
95
+ * the response rather than absent: the caller learns that the block is gone, not that the
96
+ * response forgot it.
97
+ */
98
+ export interface IReq_Any_Cloudly_DisableClusterRelayV1 extends plugins.typedrequestInterfaces.implementsTR<
99
+ plugins.typedrequestInterfaces.ITypedRequest,
100
+ IReq_Any_Cloudly_DisableClusterRelayV1
101
+ > {
102
+ method: 'disableClusterRelayV1';
103
+ request: {
104
+ identity: userInterfaces.IIdentity;
105
+ clusterId: string;
106
+ };
107
+ response: {
108
+ relay: null;
109
+ };
110
+ }
111
+
112
+ /**
113
+ * The cluster relay tells Cloudly where it listens, over its own authenticated socket.
114
+ *
115
+ * Sent after `registerCloudlyClientSessionV1`, so the cluster is the one the verified machine
116
+ * identity on that socket names and is never a request field. `Relay` names the role, not a
117
+ * component: whichever process fills it for a cluster sends this.
118
+ */
119
+ export interface IReq_Relay_Cloudly_RegisterClusterRelayV1 extends plugins.typedrequestInterfaces.implementsTR<
120
+ plugins.typedrequestInterfaces.ITypedRequest,
121
+ IReq_Relay_Cloudly_RegisterClusterRelayV1
122
+ > {
123
+ method: 'registerClusterRelayV1';
124
+ request: clusterInterfaces.IClusterRelayRegistrationV1;
125
+ response: clusterInterfaces.IClusterRelayRegistrationResponseV1;
126
+ }
127
+
69
128
  /**
70
129
  * deletes a cluster
71
130
  */