@serve.zone/interfaces 32.2.0 → 32.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 (35) hide show
  1. package/changelog.md +69 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/cluster.d.ts +30 -0
  4. package/dist_ts/data/cluster.js +46 -2
  5. package/dist_ts/data/clusternode.d.ts +21 -0
  6. package/dist_ts/data/clusternode.js +345 -11
  7. package/dist_ts/data/clustervpn.d.ts +135 -0
  8. package/dist_ts/data/clustervpn.js +147 -0
  9. package/dist_ts/data/index.d.ts +1 -0
  10. package/dist_ts/data/index.js +2 -1
  11. package/dist_ts/data/networkaddressplan.d.ts +54 -11
  12. package/dist_ts/data/networkaddressplan.js +67 -23
  13. package/dist_ts/data/networkcredential.d.ts +13 -4
  14. package/dist_ts/data/networkcredential.js +41 -23
  15. package/dist_ts/data/runtimeassignment.d.ts +23 -0
  16. package/dist_ts/data/runtimeassignment.js +42 -7
  17. package/dist_ts/platform/storage.d.ts +19 -0
  18. package/dist_ts/platform/storage.js +23 -3
  19. package/dist_ts/private/networkcontract.d.ts +6 -0
  20. package/dist_ts/private/networkcontract.js +12 -1
  21. package/dist_ts/requests/cluster.d.ts +27 -0
  22. package/dist_ts/requests/cluster.js +1 -1
  23. package/package.json +1 -1
  24. package/readme.md +139 -21
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/data/cluster.ts +76 -1
  27. package/ts/data/clusternode.ts +457 -10
  28. package/ts/data/clustervpn.ts +248 -0
  29. package/ts/data/index.ts +1 -0
  30. package/ts/data/networkaddressplan.ts +103 -27
  31. package/ts/data/networkcredential.ts +52 -24
  32. package/ts/data/runtimeassignment.ts +58 -5
  33. package/ts/platform/storage.ts +27 -5
  34. package/ts/private/networkcontract.ts +11 -0
  35. package/ts/requests/cluster.ts +35 -0
@@ -1,4 +1,4 @@
1
1
  import * as userInterfaces from '../data/user.js';
2
2
  import * as clusterInterfaces from '../data/cluster.js';
3
3
  import * as plugins from '../plugins.js';
4
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2x1c3Rlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3RzL3JlcXVlc3RzL2NsdXN0ZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLGNBQWMsTUFBTSxpQkFBaUIsQ0FBQztBQUNsRCxPQUFPLEtBQUssaUJBQWlCLE1BQU0sb0JBQW9CLENBQUM7QUFJeEQsT0FBTyxLQUFLLE9BQU8sTUFBTSxlQUFlLENBQUMifQ==
4
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2x1c3Rlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3RzL3JlcXVlc3RzL2NsdXN0ZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLGNBQWMsTUFBTSxpQkFBaUIsQ0FBQztBQUNsRCxPQUFPLEtBQUssaUJBQWlCLE1BQU0sb0JBQW9CLENBQUM7QUFLeEQsT0FBTyxLQUFLLE9BQU8sTUFBTSxlQUFlLENBQUMifQ==
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/interfaces",
3
- "version": "32.2.0",
3
+ "version": "32.4.0",
4
4
  "private": false,
5
5
  "description": "Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.",
6
6
  "exports": {
package/readme.md CHANGED
@@ -146,6 +146,22 @@ rather than with this release. `ISparkSwarmNodeHeartbeatRequest` is now owned he
146
146
  so Cloudly and Spark no longer carry private copies of it, and every Swarm-era body
147
147
  — request and answer alike — carries `protocol` and is judged in the same order.
148
148
 
149
+ Each of the four routes has its validator here, so a server never writes its own
150
+ member checks: `data.validateSparkSwarmNodeHeartbeatRequest`,
151
+ `data.validateSparkMetricsSampleRequest`, `data.validateSparkActionResultRequest`
152
+ and `data.validateSparkSwarmObservationRequest`. All four answer the same way —
153
+ one string per refused member, naming the member, and an empty list for an
154
+ admissible body — so a route table can hold them side by side. They read the
155
+ envelope first (exact key set, the sender's offer, then the reporter's identity),
156
+ then the payload: a heartbeat's metrics and full runtime description including
157
+ each described serve.zone service, a sample's four observations and optional
158
+ network rates, and an action result whose status is one a node can actually
159
+ report, never `pending`. Bodies are judged member by member rather than as
160
+ canonical bytes, because only the observation is digested and the others carry
161
+ fractional CPU, memory and rate observations; every level is copied away from the
162
+ caller's object first, so an accessor is refused instead of run. None of them
163
+ authenticates a node or acts on the body.
164
+
149
165
  Cloudly must authenticate the bearer against current Spark authority and fence
150
166
  credential generation and session epoch transactionally before recording host
151
167
  liveness. `ISparkNodeHeartbeatReceipt` contains only server-derived acceptance
@@ -474,17 +490,25 @@ The `getRuntimeManagedVpnCredential` method contract in `requests.runtimesession
474
490
  uses `IGetRuntimeManagedVpnCredentialRequest` and
475
491
  `IGetRuntimeManagedVpnCredentialResponse`. Its request carries the exact current
476
492
  session and signed projection reference. The secret response repeats those
477
- bindings and supplies a QUIC relay tuple, managed authority ID, Noise server
478
- public key, client keypair and expiry in Unix milliseconds. Keep the response
479
- process-local; never persist, hash, log or embed it in a signed projection.
493
+ bindings and supplies the `hub` tuple the node dials, the `transport` it dials it
494
+ over (`quic` or `websocket`), the managed authority ID, the Noise server public
495
+ key, the client keypair and the expiry in Unix milliseconds. `serverName` is present
496
+ exactly when the transport is `websocket` — the TLS name that hub is dialled under,
497
+ which the node cross-checks against the relay origin it stores — and absent for
498
+ `quic`, which is dialled on the address alone. The hub is the node's own cluster's
499
+ relay (see [Cluster VPN Hub And Network](#cluster-vpn-hub-and-network)). Keep the
500
+ response process-local; never persist, hash, log or embed it in a signed projection.
480
501
 
481
502
  `snapshotGetRuntimeManagedVpnCredentialRequest` and its response counterpart
482
503
  capture closed detached JSON shapes and canonical 32-byte key encodings.
483
504
  `bindGetRuntimeManagedVpnCredentialRequest(request, projection, trustedSession)`
484
505
  validates the complete projection digest and binds node, controller, namespace,
485
506
  physical-session identity and projection reference. The response binder takes
486
- `(response, request, projection, trustedSession, now, renewal = null)` and additionally requires
487
- the relay's exact UDP tuple to be selected in `router.platformEndpointIds`.
507
+ `(response, request, projection, trustedSession, now, renewal = null)` and additionally requires the
508
+ hub's exact tuple to be selected in `router.platformEndpointIds`, carrying the protocol of the
509
+ credential's transport — `udp` for `quic` and `tcp` for `websocket`, as
510
+ `runtimeNetworkAddressPlanContract.hubTransports` states. A relay therefore cannot name itself: the
511
+ endpoint has to be one the node's **signed** projection already selected.
488
512
  Expiry must be after the caller-qualified time and no later than the referenced
489
513
  projection's effective DNS window (see [DNS Lease Renewals](#dns-lease-renewals));
490
514
  acceptance before that window starts rejects.
@@ -634,30 +658,45 @@ const window = await data.getRuntimeNetworkEffectiveDnsWindow(admittedProjection
634
658
 
635
659
  `data.IRuntimeNetworkAddressPlan` is the address space an administrator gives the runtime network,
636
660
  and the single input Cloudly composes the protected authority from:
637
- `{ id: 'address-plan', revision, prefixes, pools, resolvers, platformEndpoints, vpn: { controlPrefix, relay } }`.
638
- Pools, resolvers and platform endpoints use the protected authority's own element shapes, and the
639
- relay uses the managed-VPN credential's `{ id, address, port }`. `platformEndpoints` never lists the
640
- relay: the composed authority adds it, in id order, as the UDP platform endpoint every node dials.
661
+ `{ id: 'address-plan', revision, prefixes, pools, resolvers, platformEndpoints, vpn: { controlPrefix, hubPrefix } }`.
662
+ Pools, resolvers and platform endpoints use the protected authority's own element shapes.
663
+ `controlPrefix` is the private prefix VPN control addresses are allocated from, and `hubPrefix` is
664
+ the protected prefix every cluster's VPN hub binds inside. The hub endpoints themselves are no
665
+ administrator's input: each cluster's relay reports what it bound, and the composition turns that
666
+ into platform endpoints, so `platformEndpoints` never lists one.
641
667
 
642
668
  `snapshotRuntimeNetworkAddressPlan` refuses any plan that could describe an authority the authority
643
- contract refuses. It runs `snapshotRuntimeNetworkProtectedAuthority` twice, once over the plan's
644
- own sets and once with the relay added, so overlapping pools, more than 16 resolvers, a resolver or
645
- endpoint outside `prefixes` or inside a pool, and a relay that `prefixes` do not cover are all
646
- refused by that one validator. On top it requires the VPN `controlPrefix` to be a private prefix no
647
- longer than /30 (`runtimeNetworkAddressPlanContract.maximumControlPrefixLength`) inside `prefixes`
648
- that overlaps no pool and holds no resolver or platform endpoint, the relay included. No minimum
649
- length is stated: the private-range rule already keeps it at /8 or longer.
650
- `composeRuntimeNetworkProtectedAuthority(plan, frame)` is the composition itself: the producer
669
+ contract refuses: it runs `snapshotRuntimeNetworkProtectedAuthority` over the plan's own sets, so
670
+ overlapping pools, more than 16 resolvers and a resolver or endpoint outside `prefixes` or inside a
671
+ pool are all refused by that one validator. On top it requires both VPN prefixes to sit inside
672
+ `prefixes`, to overlap no pool, to hold no resolver or platform endpoint, and never to overlap each
673
+ other — control addresses live inside the tunnel, hub endpoints on the underlay. `controlPrefix` is
674
+ additionally a private prefix no longer than /30
675
+ (`runtimeNetworkAddressPlanContract.maximumControlPrefixLength`); no minimum length is stated,
676
+ because the private-range rule already keeps it at /8 or longer.
677
+
678
+ `data.IRuntimeNetworkVpnHub { clusterId, address, quicPort, websocketPort }` is one cluster's hub as
679
+ its relay bound it, with `null` for a transport it did not bind and at least one of the two bound.
680
+ `runtimeNetworkVpnHubEndpoints(hub)` is the one derivation of what it contributes:
681
+ `<clusterId>:quic` on `udp` and `<clusterId>:ws` on `tcp`, in id order, as
682
+ `runtimeNetworkAddressPlanContract.hubTransports` states. Every reader calls it — the composition
683
+ that seals the authority and the router selection that names which endpoint a node may dial — so
684
+ two readers can never derive two ids for one hub.
685
+ `composeRuntimeNetworkProtectedAuthority(plan, frame, hubs)` is the composition itself: the producer
651
686
  supplies the frame (`TRuntimeNetworkProtectedAuthorityFrame`: authority id, generation, controller,
652
- previous reference and egress authorities) and receives the complete authority with its digest, so
653
- the validator and the protection producer can never compose differently.
687
+ previous reference and egress authorities) and the hubs of the clusters that registered one, and
688
+ receives the complete authority with its digest, so the validator and the protection producer can
689
+ never compose differently. Each hub's address must sit inside `vpn.hubPrefix` and one cluster is
690
+ named once; an empty `hubs` composes the authority of a runtime network whose clusters have no hub
691
+ yet. One hub costs up to two of the authority's 64 platform endpoints, so 32 clusters fit one
692
+ authority.
654
693
 
655
694
  `admitRuntimeNetworkAddressPlanChange(previous | null, next)` is the pure decision for a plan write at
656
695
  the following revision; any other revision is a contract violation. Leases and the managed VPN
657
696
  outlive a plan revision, so it answers:
658
697
 
659
698
  - `unchanged` when the content equals the stored plan; Cloudly writes nothing;
660
- - `refused` with `plan-vpn-immutable` when the VPN differs, `plan-pool-removed` when any pool is gone
699
+ - `refused` with `plan-vpn-immutable` when either VPN prefix differs, `plan-pool-removed` when any pool is gone
661
700
  or changed its purpose or prefix, and `plan-prefix-shrunk` when the new prefixes, however split,
662
701
  no longer cover every address the old ones did;
663
702
  - `accepted` with the ids of the resolvers and platform endpoints the change removes or changes. An
@@ -706,7 +745,19 @@ rule read the other way, for a receiver checking a list it was sent. Canonical i
706
745
  strictly increasing in the code-unit order of the names themselves — a rule a peer
707
746
  in another language implements from the vocabulary alone, rather than from the
708
747
  order this package happens to declare it in — and both directions compare through
709
- that one rule, so neither can drift from the other. All fields except the digest itself participate in a domain-separated
748
+ that one rule, so neither can drift from the other.
749
+
750
+ `workload.registryHost` is the registry that published the image, which is the publication identity
751
+ the image release is bound to, and it is never rewritten. `workload.pullEndpoint` is the optional
752
+ `host[:port]` the node fetches the bytes from instead — a cluster whose relay forwards the registry
753
+ states the relay's own origin, so a node pulls inside its cluster and nothing cluster-side dials the
754
+ control plane. `data.buildRuntimeAssignmentImageReference(workload)` is the one place that choice is
755
+ made: `pullEndpoint ?? registryHost`, digest-pinned either way, so the endpoint decides reachability
756
+ and never content, and a controller and a node cannot disagree about the reference. Absence stays
757
+ absence: an assignment that states no endpoint is different content, and so a different digest, from
758
+ one that does.
759
+
760
+ All fields except the digest itself participate in a domain-separated
710
761
  SHA-256 digest. Digests provide integrity and compare-and-swap identity;
711
762
  authentication must separately establish the trusted controller/node scope.
712
763
 
@@ -1308,6 +1359,25 @@ around the request. A body that is not a storage request is refused with
1308
1359
  taken over. `platform.storageRequestCanonicalDigestGoldenVectors` states both
1309
1360
  for a filesystem request and for both object-storage deliveries.
1310
1361
 
1362
+ Which requests exist at all is stated by the same contract:
1363
+
1364
+ ```typescript
1365
+ const storageRequest = platform.normalizeStorageRequest(declaredRequest);
1366
+ ```
1367
+
1368
+ `platform.normalizeStorageRequest` reads a body against the exact request schema
1369
+ and returns the request in its contract shape — required members present,
1370
+ optional members carried only when declared, canonical environment keys, no
1371
+ empty `protection` block, no explicitly undefined member, no `-0` or unsafe
1372
+ integer, and plain JSON data members only. It is the value the other two
1373
+ functions are derived from: canonicalizing and digesting a request normalize it
1374
+ first, so a consumer that admits a request through this function and a control
1375
+ plane that digests it cannot accept different sets of requests. The input is
1376
+ never mutated and the returned request is a fresh object. A body that is not a
1377
+ storage request is refused with `platform.StorageContractError`, whose message
1378
+ names the failing member so a consumer can map the refusal to its own status
1379
+ vocabulary instead of restating the rules.
1380
+
1311
1381
  Portable manifests and resolved bindings intentionally have no fields for
1312
1382
  Synology, NFS, Kerberos, Corestore, Kubernetes, Docker drivers, servers,
1313
1383
  exports, mount options, provider credential values, or local fallback paths. Runtimes
@@ -1631,6 +1701,54 @@ const registrationReasons = data.validateRegisterClusterRelayRequest(registratio
1631
1701
  How the relay reaches each node's Corestore is part of the same document: see
1632
1702
  [Corestore Through The Relay](#corestore-through-the-relay).
1633
1703
 
1704
+ ### Cluster VPN Hub And Network
1705
+
1706
+ A cluster's relay is also that cluster's managed VPN hub: every node of the cluster dials it, and
1707
+ an inter-node workload packet is relayed inside the cluster instead of crossing the control plane.
1708
+ Cloudly keeps every authority — it compiles the network and issues each node's credential — and
1709
+ carries no packet.
1710
+
1711
+ A relay that bound a hub says so in its registration, in `relay.vpnHub`:
1712
+ `data.IClusterRelayVpnHub { publicKey, address, quicPort, websocketPort }`. It reports what it
1713
+ bound, exactly like `listenAddress` and `listenPort`: `address` is an IPv4 literal inside the
1714
+ address plan's `vpn.hubPrefix`, a transport it did not bind is `null`, and at least one of the two
1715
+ is bound. `publicKey` is the hub's Noise public key in the one canonical 32-byte base64 encoding
1716
+ every Noise key of this contract is read with — this advertisement, the managed VPN credential's
1717
+ keys and a cluster network's nodes all pass the same reader. A relay names no endpoint id and no
1718
+ TLS name of its own: Cloudly composes the endpoints with `runtimeNetworkVpnHubEndpoints` (see
1719
+ [Runtime Network Address Plan](#runtime-network-address-plan)) and states the TLS name from the
1720
+ origin it published for that relay. The member is optional — a relay that serves no hub registers
1721
+ exactly as before, and Cloudly selects no hub for that cluster, stating `relay-vpn-hub-absent`.
1722
+ `validateClusterRelayVpnHub(value, path?)` returns one named reason per violation.
1723
+
1724
+ `data.IClusterVpnNetwork { authorityId, revision, nodes, grants }` is the whole managed VPN of one
1725
+ cluster. It is never a patch: a node or a grant the network does not carry is withdrawn, and
1726
+ membership is presence, so there is no enabled flag. Each node states `{ nodeId, publicKey,
1727
+ controlAddress, expiresAt, workloadPrefixes }`, where `expiresAt` is that node's credential expiry
1728
+ in Unix milliseconds and each prefix is `{ cidr, policyDomain }`. Each grant is one directed
1729
+ `{ sourceDomain, destinationDomain }` pair. `clusterVpnControlPolicyDomain(address)` and
1730
+ `clusterVpnWorkloadPolicyDomain(leaseDigest)` state the domain names once for both sides.
1731
+
1732
+ `snapshotClusterVpnNetwork` requires sorted, unique members, prefixes and grants, and requires
1733
+ every grant to join two workload domains the network itself carries, on two different nodes. That
1734
+ is what a per-cluster hub means: a grant whose other end sits on another cluster's hub cannot be
1735
+ expressed at all, which is why Cloudly refuses it while it compiles, by name, with
1736
+ `grant-cross-cluster-unsupported` from `data.clusterVpnNetworkSelectionRefusals`.
1737
+ `clusterVpnNetworkContract` bounds a network at 256 nodes, 128 workload prefixes per node, 4096
1738
+ grants and 917 504 canonical bytes.
1739
+
1740
+ `applyClusterVpnNetwork { network }` is Cloudly's push over the relay's already registered session;
1741
+ the relay answers `{ appliedRevision, state }`, where `state` is `idle`, `applying` or `failed` and
1742
+ `idle` means the pushed revision is the applied one — which is what
1743
+ `bindApplyClusterVpnNetworkResponse(response, sentRequest)` checks. `admitClusterVpnNetwork(held |
1744
+ null, next)` is the pure decision the relay makes against what its hub holds: `accepted` for a
1745
+ higher revision or an empty hub, `replay` for a repeat, and `refused` with
1746
+ `vpn-network-revision-stale` or `vpn-network-authority-foreign` from
1747
+ `data.applyClusterVpnNetworkRefusals`. `getClusterVpnNetwork` is the pull the other way, with no
1748
+ request body — the relay's socket names the cluster — answering `{ network }` or `{ network: null }`
1749
+ while the cluster has none. A hub daemon starts empty and its lifetime is the relay's own, so a
1750
+ relay that just bound one pulls instead of waiting for the next push.
1751
+
1634
1752
  ### Cluster Ingress
1635
1753
 
1636
1754
  A cluster's ingress terminates TLS for the hostnames its services publish and forwards to the
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.2.0',
6
+ version: '32.4.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -5,6 +5,7 @@ import type { IClusterNode } from './clusternode.js';
5
5
  import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
6
6
  import { validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
7
7
  import { hasExactKeys } from '../private/dataproperties.js';
8
+ import { networkNoiseKey } from '../private/networkcontract.js';
8
9
 
9
10
  export interface ICluster {
10
11
  id: string;
@@ -62,6 +63,25 @@ export interface IClusterRelay {
62
63
  origin: string;
63
64
  }
64
65
 
66
+ /**
67
+ * The managed VPN hub a cluster relay bound for its own cluster.
68
+ *
69
+ * The relay is its cluster's packet relay: every node of the cluster dials this hub and no
70
+ * inter-node packet leaves the cluster. It reports what it bound, exactly like `listenAddress` and
71
+ * `listenPort`, and Cloudly composes the platform endpoints from it — a relay names no endpoint id
72
+ * and no TLS name of its own. `publicKey` is the hub's Noise public key in the one canonical
73
+ * 32-byte base64 encoding every Noise key of this contract is read with; it is public material and
74
+ * authenticates nothing by itself. A hub that bound only one transport reports the other port as
75
+ * `null`.
76
+ */
77
+ export interface IClusterRelayVpnHub {
78
+ publicKey: string;
79
+ /** The address the hub bound, as an IPv4 literal inside the address plan's `vpn.hubPrefix`. */
80
+ address: string;
81
+ quicPort: number | null;
82
+ websocketPort: number | null;
83
+ }
84
+
65
85
  /**
66
86
  * What a cluster relay tells Cloudly about itself once its socket is authenticated.
67
87
  *
@@ -88,6 +108,11 @@ export interface IRegisterClusterRelayRequest {
88
108
  */
89
109
  listenAddress: string;
90
110
  listenPort: number;
111
+ /**
112
+ * Absent on a relay that serves no managed VPN hub, which registers exactly as it did before.
113
+ * Cloudly selects no hub for such a cluster and states `relay-vpn-hub-absent` instead.
114
+ */
115
+ vpnHub?: IClusterRelayVpnHub;
91
116
  };
92
117
  /** Monotonic within one relay process; it restarts at 0 when the process restarts. */
93
118
  registrationSequence: number;
@@ -234,9 +259,56 @@ const validateClusterRelayNonNegativeInteger = (valueArg: unknown, pathArg: stri
234
259
  : [`${pathArg} must be a non-negative safe integer`]
235
260
  );
236
261
 
262
+ /**
263
+ * The one canonical 32-byte base64 reading every Noise key of this contract passes, stated as a
264
+ * refusal this validator's callers can collect: a hub key is the same key material the managed VPN
265
+ * credential carries, so it is never read by a second rule that could drift from it.
266
+ */
267
+ const validateClusterRelayVpnKey = (valueArg: unknown, pathArg: string): string[] => {
268
+ try {
269
+ networkNoiseKey(valueArg);
270
+ return [];
271
+ } catch {
272
+ return [`${pathArg} must be a canonical 32-byte base64 public key`];
273
+ }
274
+ };
275
+
276
+ /** A port the hub bound, or `null` for a transport it did not bind. */
277
+ const validateClusterRelayHubPort = (valueArg: unknown, pathArg: string): string[] => (
278
+ valueArg === null || validateClusterRelayListenPort(valueArg, pathArg).length === 0
279
+ ? []
280
+ : [`${pathArg} must be null or an integer from 1 to 65535`]
281
+ );
282
+
283
+ /**
284
+ * Validates one hub advertisement and names every reason it is refused.
285
+ *
286
+ * Shape only: whether the address sits inside the address plan's `vpn.hubPrefix` is Cloudly's to
287
+ * decide against the plan it stores, and the endpoints themselves are composed there.
288
+ */
289
+ export const validateClusterRelayVpnHub = (
290
+ valueArg: unknown,
291
+ pathArg = 'cluster relay vpn hub',
292
+ ): string[] => {
293
+ if (!isRecord(valueArg)
294
+ || !hasExactKeys(valueArg, ['publicKey', 'address', 'quicPort', 'websocketPort'])) {
295
+ return [`${pathArg} must use its exact schema`];
296
+ }
297
+ const errors = [
298
+ ...validateClusterRelayVpnKey(valueArg.publicKey, `${pathArg} publicKey`),
299
+ ...validateClusterRelayListenAddress(valueArg.address, `${pathArg} address`),
300
+ ...validateClusterRelayHubPort(valueArg.quicPort, `${pathArg} quicPort`),
301
+ ...validateClusterRelayHubPort(valueArg.websocketPort, `${pathArg} websocketPort`),
302
+ ];
303
+ if (valueArg.quicPort === null && valueArg.websocketPort === null) {
304
+ errors.push(`${pathArg} must bind at least one of quicPort and websocketPort`);
305
+ }
306
+ return errors;
307
+ };
308
+
237
309
  const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): string[] => {
238
310
  if (!isRecord(valueArg)
239
- || !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'])) {
311
+ || !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'], ['vpnHub'])) {
240
312
  return [`${pathArg} must use its exact schema`];
241
313
  }
242
314
  return [
@@ -244,6 +316,9 @@ const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): strin
244
316
  ...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
245
317
  ...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
246
318
  ...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
319
+ ...(Object.keys(valueArg).includes('vpnHub')
320
+ ? validateClusterRelayVpnHub(valueArg.vpnHub, `${pathArg} vpnHub`)
321
+ : []),
247
322
  ];
248
323
  };
249
324