@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
package/readme.md CHANGED
@@ -1113,6 +1113,132 @@ const certificateDomainName = data.clusterRelayCertificateDomainName(relay);
1113
1113
  // 'relay.cluster-a.serve.zone'
1114
1114
  ```
1115
1115
 
1116
+ Enabling a relay is one operator flip per cluster. `enableClusterRelayV1 { identity, clusterId }`
1117
+ answers with the block Cloudly published, and `disableClusterRelayV1` removes it and answers with
1118
+ an explicit `relay: null`. The caller names only the cluster: Cloudly derives the origin itself
1119
+ from that cluster and its own public hostname, and refuses the request when no DNS zone it
1120
+ manages matches the derived name, so a relay name that nothing can publish is never written.
1121
+
1122
+ Once its socket is authenticated with `registerCloudlyClientSessionV1`, the relay sends
1123
+ `registerClusterRelayV1` carrying `IClusterRelayRegistrationV1`: the relay build, the cluster node
1124
+ it runs on, the exact endpoint it bound for its cluster's Pallet nodes, and a
1125
+ `registrationSequence` that counts up once per registration within one relay process and restarts
1126
+ at `0` when that process restarts. `validateClusterRelayRegistrationV1(value, path?)` returns one
1127
+ named reason per violation, like every validator here, and checks shape only. `listenAddress` is
1128
+ an IPv4 literal because Cloudly publishes it verbatim as the A record of the relay's certificate
1129
+ domain name — a hostname would need a `CNAME` and an IPv6 address an `AAAA` record, so admitting
1130
+ either is an additive change once Cloudly publishes it. A loopback or link-local address is
1131
+ refused by its own name, because a relay that bound one can never serve the nodes of its cluster.
1132
+
1133
+ Authority stays with Cloudly, and none of it is a request field. The cluster is the one the
1134
+ verified machine identity on that socket names; whether `relay.nodeId` is a node of that cluster
1135
+ is decided against Cloudly's own records; and one cluster has one relay, so when two peers are
1136
+ live the one holding the highest accepted `registrationSequence` is the relay and the other is
1137
+ never selected. The response carries the origin Cloudly published, or `null` while the cluster
1138
+ carries no relay block, which a relay must read as "not enabled yet" rather than as licence to
1139
+ invent an origin: it is how the relay checks that the certificate it holds and the name its nodes
1140
+ are told to reach are the same one.
1141
+
1142
+ ```typescript
1143
+ const registration: data.IClusterRelayRegistrationV1 = {
1144
+ schemaVersion: 1,
1145
+ relay: {
1146
+ version: '4.0.0',
1147
+ nodeId: 'node-a',
1148
+ listenAddress: '10.0.4.7',
1149
+ listenPort: 8443,
1150
+ },
1151
+ registrationSequence: 0,
1152
+ };
1153
+
1154
+ const registrationReasons = data.validateClusterRelayRegistrationV1(registration); // []
1155
+ ```
1156
+
1157
+ ### Cluster Ingress
1158
+
1159
+ A cluster's ingress terminates TLS for the hostnames its services publish and forwards to the
1160
+ host ports Pallet published for the workloads behind them. Cloudly computes what it serves — it
1161
+ is the only party that knows every service, every ready endpoint, the node address each published
1162
+ port answers on and the certificate each hostname needs — and the cluster relay carries that table
1163
+ to the ingress and the ingress's counters back. These contracts carry no version suffix and no
1164
+ schema field: the protocol version is the installed `@serve.zone/interfaces` version.
1165
+
1166
+ `IClusterRouteTable { revision, issuedAt, routes, portRoutes }` is the whole table, never a delta.
1167
+ `revision` is its only idempotency: a relay and an ingress apply a table whose revision is greater
1168
+ than the one they hold and ignore anything else, so a repeated push, a replayed push and a push
1169
+ that crossed a newer one all settle the same way. `IClusterHttpRoute` names one hostname, its
1170
+ `destinations` (`{ address, port }`, unicast IPv4 and a real port), the `certificate` that
1171
+ terminates it and optional Basic `authentication`; `IClusterPortRoute` forwards a TCP or UDP
1172
+ listen port without terminating anything. `validateClusterRouteTable(value, path?)` returns one
1173
+ named reason per violation, refuses two routes for one hostname and two port routes for one
1174
+ transport and port, and — because a validation reason travels into logs — **never quotes
1175
+ certificate material or a password in a reason**.
1176
+
1177
+ The table is secret-bearing end to end: it carries the private key of every hostname the ingress
1178
+ serves. Exclude it from hooks, logs, journals and disk on every hop, exactly as a runtime session
1179
+ credential is excluded.
1180
+
1181
+ | Method | Direction | Body |
1182
+ | --- | --- | --- |
1183
+ | `pushClusterRouteTable` | Cloudly → relay | `{ table }` → `{ appliedRevision }` |
1184
+ | `getClusterRouteTable` | relay → Cloudly | `{}` → `{ table \| null }` |
1185
+ | `registerClusterIngress` | ingress → relay → Cloudly | `{ bearer, version, nodeId, capabilities? }` → `{ accepted: true, revision \| null }` |
1186
+ | `getClusterIngressRouteTable` | ingress → relay | `{ knownRevision \| null }` → `{ table \| null }` |
1187
+ | `getClusterTrafficStatistics` | Cloudly → relay → ingress | `{ fromDayUtc, toDayUtc, cursor?, limit }` → `{ statistics, nextCursor? }` |
1188
+
1189
+ `null` for a table means Cloudly has computed none yet — a fact, not an empty table, and an
1190
+ ingress must not serve it as one. An ingress announces itself with a **method**, not a connection
1191
+ tag: the relay learns what connected from what it registered through, which a server owns, rather
1192
+ than from a label a client authored.
1193
+
1194
+ **Who may be the ingress is Cloudly's decision, not the relay's.** The ingress shares the relay's
1195
+ node listener, so anything that reaches it could ask to register, and a relay cannot tell an
1196
+ ingress from anything else. `registerClusterIngress` therefore carries the `bearer` Cloudly minted
1197
+ into the ingress workload's assignment environment and travels both hops with one body: the
1198
+ ingress sends it to its relay, the relay forwards it untouched, and Cloudly verifies the bearer
1199
+ and derives the cluster from the identity tag on the relay's own session. The bearer is sensitive
1200
+ ephemeral input — excluded from hooks, logs, diagnostics and journals on every hop, and never
1201
+ quoted in a validation reason.
1202
+
1203
+ Isolation on that shared listener is therefore by **authorization, not by routing**: only the
1204
+ connection whose registration Cloudly accepted is the ingress, `getClusterIngressRouteTable` and
1205
+ `getClusterTrafficStatistics` are answered for that connection alone, and any other connection —
1206
+ including a Pallet node's — is refused by name. `validateClusterIngressRegistration(value, path?)`
1207
+ checks shape only: whether the bearer is the one Cloudly minted is Cloudly's to verify.
1208
+
1209
+ `IClusterTrafficStatistics` is one UTC day of one hostname's traffic on one node —
1210
+ `requests`, `bytesIn`, `bytesOut`, `connections` and the backend failures the proxy can observe
1211
+ (`connect`, `handshake`, `request`). Day buckets rather than rates: a rate answers "how busy is it
1212
+ now", which the live proxy already answers, while a bucket survives a restart, a poll that
1213
+ straddles midnight and a Cloudly outage. HTTP status classes are deliberately absent — the proxy
1214
+ exposes no status counters today, and a number this contract cannot source would be a guess.
1215
+ Readers page it exactly like CoreMail's mail statistics — the same `fromDayUtc`, `toDayUtc`,
1216
+ `cursor` and `limit`, answered as `{ statistics, nextCursor? }` — re-reading yesterday and today on
1217
+ every pass because a day only stops changing once it has closed. `dayUtc` must name a day that
1218
+ exists: the shared UTC calendar-day rule round-trips through `Date.UTC`, so `2026-13-45` is
1219
+ refused rather than stored.
1220
+
1221
+ ```typescript
1222
+ const reasons = data.validateClusterRouteTable(table); // []
1223
+ const pageReasons = data.validateClusterTrafficStatisticsPage({ statistics: [] }); // []
1224
+ const ingressReasons = data.validateClusterIngressRegistration({
1225
+ bearer: ingressBearerFromAssignmentEnvironment,
1226
+ version: '3.0.0',
1227
+ nodeId: 'node-a',
1228
+ }); // []
1229
+ ```
1230
+
1231
+ ### Node Uplink Address
1232
+
1233
+ A route to a node's published host ports needs the address that node answers on, and only the
1234
+ node can prove it: it holds the uplink observation the address belongs to. It therefore states it
1235
+ on the network report it already sends — `IReportRuntimeNetworkProtectionRequestV1` gains the
1236
+ optional `uplinkAddress`, a unicast IPv4 literal that is neither loopback nor link-local
1237
+ (`runtimeNodeUplinkAddress`). Absence stays absent: a node that states no address is told apart
1238
+ from one that states a value, and nothing downstream defaults or infers one. Cloudly stores what
1239
+ the node stated and resolves it into the route table; the relay never substitutes an address of
1240
+ its own.
1241
+
1116
1242
  Shared service port helpers are exported from `data` so Cloudly, App Store resolution, Coreflow, Coretraffic, Onebox, and dcrouter agree on the same normalization rules:
1117
1243
 
1118
1244
  ```typescript
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '31.2.0',
6
+ version: '31.4.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
2
2
 
3
3
  import { type IDockerRegistryInfo } from '../data/docker.js';
4
4
  import type { IClusterNode } from './clusternode.js';
5
+ import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
5
6
 
6
7
  export interface ICluster {
7
8
  id: string;
@@ -60,7 +61,52 @@ export interface IClusterRelayV1 {
60
61
  origin: string;
61
62
  }
62
63
 
64
+ /**
65
+ * What a cluster relay tells Cloudly about itself once its socket is authenticated.
66
+ *
67
+ * The relay sends this after `registerCloudlyClientSessionV1`, so the cluster it registers for
68
+ * is the one its verified machine identity names and is never a field here. `relay.nodeId` is
69
+ * the cluster node the relay process runs on, `listenAddress` and `listenPort` are the exact
70
+ * endpoint it bound for the Pallet nodes of its cluster, and `registrationSequence` counts up
71
+ * once per registration within one relay process, so Cloudly can tell a newer registration from
72
+ * a replayed older one while both peers are live.
73
+ */
74
+ export interface IClusterRelayRegistrationV1 {
75
+ schemaVersion: 1;
76
+ relay: {
77
+ /** The relay build that is registering; a bounded label for operators and logs. */
78
+ version: string;
79
+ nodeId: string;
80
+ /**
81
+ * The address the relay bound, as an IPv4 literal.
82
+ *
83
+ * Cloudly publishes this value verbatim as the A record of the relay's certificate domain
84
+ * name, so the contract admits exactly what that record can carry: a hostname would need a
85
+ * `CNAME` and an IPv6 address an `AAAA` record, and neither is published today. Admitting
86
+ * either is an additive change once Cloudly publishes it.
87
+ */
88
+ listenAddress: string;
89
+ listenPort: number;
90
+ };
91
+ /** Monotonic within one relay process; it restarts at 0 when the process restarts. */
92
+ registrationSequence: number;
93
+ }
94
+
95
+ /**
96
+ * What Cloudly tells the relay back.
97
+ *
98
+ * `origin` is the origin Cloudly has published for this cluster, so a relay can check that the
99
+ * certificate it holds and the name its nodes are told to reach are the same one. It is `null`
100
+ * while the cluster carries no relay block, which a relay must treat as "not enabled yet"
101
+ * rather than as an origin it may invent.
102
+ */
103
+ export interface IClusterRelayRegistrationResponseV1 {
104
+ accepted: true;
105
+ origin: string | null;
106
+ }
107
+
63
108
  const maximumClusterRelayOriginLength = 2048;
109
+ const maximumClusterRelayVersionLength = 64;
64
110
 
65
111
  const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
66
112
  Boolean(valueArg)
@@ -130,6 +176,86 @@ export const validateClusterRelayV1 = (
130
176
  return errors;
131
177
  };
132
178
 
179
+ const validateClusterRelayVersion = (valueArg: unknown, pathArg: string): string[] => (
180
+ typeof valueArg === 'string'
181
+ && valueArg.length <= maximumClusterRelayVersionLength
182
+ && /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(valueArg)
183
+ ? []
184
+ : [`${pathArg} must be 1 to ${maximumClusterRelayVersionLength} characters of letters, `
185
+ + 'digits, dot, plus, underscore or hyphen, starting with a letter or digit']
186
+ );
187
+
188
+ /** The same node id rule a node's own runtime session registers under. */
189
+ const validateClusterRelayNodeId = (valueArg: unknown, pathArg: string): string[] => (
190
+ typeof valueArg === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(valueArg)
191
+ ? []
192
+ : [`${pathArg} must be 1 to 128 characters of letters, digits, underscore or hyphen`]
193
+ );
194
+
195
+ const validateClusterRelayListenAddress = (valueArg: unknown, pathArg: string): string[] => {
196
+ if (!isPlausibleUnicastIpv4(valueArg)) {
197
+ return [`${pathArg} must be a unicast IPv4 literal`];
198
+ }
199
+ // A relay that bound one of these can never serve the nodes of its cluster, and publishing
200
+ // it would put an unreachable address into every node's relay origin.
201
+ if (valueArg.startsWith('127.')) {
202
+ return [`${pathArg} must not be a loopback address`];
203
+ }
204
+ if (valueArg.startsWith('169.254.')) {
205
+ return [`${pathArg} must not be a link-local address`];
206
+ }
207
+ return [];
208
+ };
209
+
210
+ const validateClusterRelayListenPort = (valueArg: unknown, pathArg: string): string[] => (
211
+ typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg >= 1 && valueArg <= 65535
212
+ ? []
213
+ : [`${pathArg} must be an integer from 1 to 65535`]
214
+ );
215
+
216
+ const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): string[] => {
217
+ if (!isRecord(valueArg)
218
+ || !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'])) {
219
+ return [`${pathArg} must use its exact schema`];
220
+ }
221
+ return [
222
+ ...validateClusterRelayVersion(valueArg.version, `${pathArg} version`),
223
+ ...validateClusterRelayNodeId(valueArg.nodeId, `${pathArg} nodeId`),
224
+ ...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
225
+ ...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
226
+ ];
227
+ };
228
+
229
+ /**
230
+ * Validates one cluster relay registration and names every reason it is refused.
231
+ *
232
+ * Shape only, exactly like every other validator here: which cluster the relay speaks for comes
233
+ * from the verified identity on its socket, whether `relay.nodeId` is a node of that cluster is
234
+ * Cloudly's to decide against its own records, and whether this registration supersedes a live
235
+ * one is Cloudly's comparison of `registrationSequence` between peers. A registration that
236
+ * passes here has claimed nothing.
237
+ */
238
+ export const validateClusterRelayRegistrationV1 = (
239
+ valueArg: unknown,
240
+ pathArg = 'cluster relay registration',
241
+ ): string[] => {
242
+ if (!isRecord(valueArg)
243
+ || !hasExactKeys(valueArg, ['schemaVersion', 'relay', 'registrationSequence'])) {
244
+ return [`${pathArg} must use its exact schema`];
245
+ }
246
+ const errors: string[] = [];
247
+ if (valueArg.schemaVersion !== 1) {
248
+ errors.push(`${pathArg} schemaVersion must be 1`);
249
+ }
250
+ errors.push(...validateClusterRelayEndpoint(valueArg.relay, `${pathArg} relay`));
251
+ if (typeof valueArg.registrationSequence !== 'number'
252
+ || !Number.isSafeInteger(valueArg.registrationSequence)
253
+ || valueArg.registrationSequence < 0) {
254
+ errors.push(`${pathArg} registrationSequence must be a non-negative safe integer`);
255
+ }
256
+ return errors;
257
+ };
258
+
133
259
  /**
134
260
  * The name the relay's certificate is issued for, and the A record Cloudly
135
261
  * owns for it. Derived from the origin so every consumer derives it