@serve.zone/interfaces 32.3.0 → 32.5.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 (61) hide show
  1. package/changelog.md +88 -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 +48 -3
  5. package/dist_ts/data/clusteringress.js +9 -8
  6. package/dist_ts/data/clusternode.d.ts +6 -1
  7. package/dist_ts/data/clusternode.js +32 -5
  8. package/dist_ts/data/clustervpn.d.ts +135 -0
  9. package/dist_ts/data/clustervpn.js +147 -0
  10. package/dist_ts/data/index.d.ts +1 -0
  11. package/dist_ts/data/index.js +2 -1
  12. package/dist_ts/data/isolatedrestore.js +12 -4
  13. package/dist_ts/data/networkaddressplan.d.ts +54 -11
  14. package/dist_ts/data/networkaddressplan.js +67 -23
  15. package/dist_ts/data/networkauthority.js +9 -3
  16. package/dist_ts/data/networkcredential.d.ts +13 -4
  17. package/dist_ts/data/networkcredential.js +41 -23
  18. package/dist_ts/data/networklease.js +3 -3
  19. package/dist_ts/data/networknode.js +3 -3
  20. package/dist_ts/data/networkprojection.js +8 -3
  21. package/dist_ts/data/nodeenrollment.js +4 -2
  22. package/dist_ts/data/noderetirement.js +3 -3
  23. package/dist_ts/data/runtimeassignment.d.ts +35 -0
  24. package/dist_ts/data/runtimeassignment.js +70 -10
  25. package/dist_ts/data/runtimesession.js +3 -2
  26. package/dist_ts/data/serviceruntime.js +6 -5
  27. package/dist_ts/data/sparknodereport.js +6 -3
  28. package/dist_ts/private/networkcontract.d.ts +8 -0
  29. package/dist_ts/private/networkcontract.js +19 -2
  30. package/dist_ts/private/nodeidentifier.d.ts +22 -0
  31. package/dist_ts/private/nodeidentifier.js +23 -0
  32. package/dist_ts/requests/cluster.d.ts +27 -0
  33. package/dist_ts/requests/cluster.js +1 -1
  34. package/dist_ts/requests/corestore.js +4 -4
  35. package/dist_ts/runtime.js +7 -4
  36. package/package.json +1 -1
  37. package/readme.md +132 -21
  38. package/ts/00_commitinfo_data.ts +1 -1
  39. package/ts/data/cluster.ts +78 -2
  40. package/ts/data/clusteringress.ts +8 -7
  41. package/ts/data/clusternode.ts +32 -4
  42. package/ts/data/clustervpn.ts +248 -0
  43. package/ts/data/index.ts +1 -0
  44. package/ts/data/isolatedrestore.ts +12 -3
  45. package/ts/data/networkaddressplan.ts +103 -27
  46. package/ts/data/networkauthority.ts +8 -2
  47. package/ts/data/networkcredential.ts +52 -24
  48. package/ts/data/networklease.ts +2 -2
  49. package/ts/data/networknode.ts +2 -2
  50. package/ts/data/networkprojection.ts +6 -2
  51. package/ts/data/nodeenrollment.ts +4 -2
  52. package/ts/data/noderetirement.ts +2 -2
  53. package/ts/data/runtimeassignment.ts +84 -8
  54. package/ts/data/runtimesession.ts +3 -2
  55. package/ts/data/serviceruntime.ts +6 -5
  56. package/ts/data/sparknodereport.ts +7 -2
  57. package/ts/private/networkcontract.ts +17 -1
  58. package/ts/private/nodeidentifier.ts +25 -0
  59. package/ts/requests/cluster.ts +35 -0
  60. package/ts/requests/corestore.ts +3 -3
  61. package/ts/runtime.ts +6 -3
package/readme.md CHANGED
@@ -31,6 +31,34 @@ import { appstore, data, platform, platformservice, protocol, requests } from '@
31
31
 
32
32
  This package intentionally has no service implementation logic. It is a stable vocabulary for services that need to agree on payload shape, method names, and response types.
33
33
 
34
+ ### Identifier Vocabularies
35
+
36
+ Two vocabularies name things in these contracts, and which one a member uses is part of the
37
+ contract.
38
+
39
+ | Vocabulary | Rule | Members |
40
+ | --- | --- | --- |
41
+ | Canonical identifier | `^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$` — first character alphanumeric, up to 200 characters | organizations, clusters, services, namespaces, sessions, assignments, attempts, authorities, pools, endpoints and every content id derived from them |
42
+ | Node id | `^[A-Za-z0-9_-]{1,128}$` — the URL alphabet, a leading `-` or `_` included, up to 128 characters | every member that names a node of a cluster |
43
+
44
+ A node may be named `-abc` or `_abc`: enrollment, the runtime session registration and the Spark
45
+ wire admit a node id that begins with a separator, so every member that names the same node admits
46
+ one too. `data.isClusterNodeId(value)` is that rule, and it is the rule at every node-bearing member:
47
+ `nodeId` on a session binding, a registration, an enrollment, a Spark heartbeat, a relay block, an
48
+ assignment, a workload lease, a handoff lease, a DNS-lease renewal run, a protection receipt, an
49
+ egress authority, a cluster VPN node, an ingress registration, a traffic bucket, a Corestore
50
+ inventory and an isolated-restore node control; `id` on a router incarnation and on a node
51
+ retirement; `cloudlyNodeId` on a cluster runtime target and on a secret runtime target; and
52
+ `placement.nodeIds` on a service runtime spec. A service that stores or forwards a node id
53
+ should judge it with the same reader rather than with the canonical one, which would refuse a node
54
+ these contracts themselves enrolled and registered.
55
+
56
+ A replica id is the one composed name: a producer names a replica slot `<node id>.<index>`, counted
57
+ from zero. `data.isRuntimeReplicaId(value)` reads it by decomposition — the part before the last dot
58
+ in the node vocabulary, the index on its own and never zero-padded — and reads a name that carries
59
+ no such index, a zero-padded `node-a.01` among them, as an opaque slot name in the canonical
60
+ identifier vocabulary.
61
+
34
62
  WorkloadInit release verifiers should use the dedicated Node-compatible subpath:
35
63
 
36
64
  ```typescript
@@ -490,17 +518,25 @@ The `getRuntimeManagedVpnCredential` method contract in `requests.runtimesession
490
518
  uses `IGetRuntimeManagedVpnCredentialRequest` and
491
519
  `IGetRuntimeManagedVpnCredentialResponse`. Its request carries the exact current
492
520
  session and signed projection reference. The secret response repeats those
493
- bindings and supplies a QUIC relay tuple, managed authority ID, Noise server
494
- public key, client keypair and expiry in Unix milliseconds. Keep the response
495
- process-local; never persist, hash, log or embed it in a signed projection.
521
+ bindings and supplies the `hub` tuple the node dials, the `transport` it dials it
522
+ over (`quic` or `websocket`), the managed authority ID, the Noise server public
523
+ key, the client keypair and the expiry in Unix milliseconds. `serverName` is present
524
+ exactly when the transport is `websocket` — the TLS name that hub is dialled under,
525
+ which the node cross-checks against the relay origin it stores — and absent for
526
+ `quic`, which is dialled on the address alone. The hub is the node's own cluster's
527
+ relay (see [Cluster VPN Hub And Network](#cluster-vpn-hub-and-network)). Keep the
528
+ response process-local; never persist, hash, log or embed it in a signed projection.
496
529
 
497
530
  `snapshotGetRuntimeManagedVpnCredentialRequest` and its response counterpart
498
531
  capture closed detached JSON shapes and canonical 32-byte key encodings.
499
532
  `bindGetRuntimeManagedVpnCredentialRequest(request, projection, trustedSession)`
500
533
  validates the complete projection digest and binds node, controller, namespace,
501
534
  physical-session identity and projection reference. The response binder takes
502
- `(response, request, projection, trustedSession, now, renewal = null)` and additionally requires
503
- the relay's exact UDP tuple to be selected in `router.platformEndpointIds`.
535
+ `(response, request, projection, trustedSession, now, renewal = null)` and additionally requires the
536
+ hub's exact tuple to be selected in `router.platformEndpointIds`, carrying the protocol of the
537
+ credential's transport — `udp` for `quic` and `tcp` for `websocket`, as
538
+ `runtimeNetworkAddressPlanContract.hubTransports` states. A relay therefore cannot name itself: the
539
+ endpoint has to be one the node's **signed** projection already selected.
504
540
  Expiry must be after the caller-qualified time and no later than the referenced
505
541
  projection's effective DNS window (see [DNS Lease Renewals](#dns-lease-renewals));
506
542
  acceptance before that window starts rejects.
@@ -650,30 +686,45 @@ const window = await data.getRuntimeNetworkEffectiveDnsWindow(admittedProjection
650
686
 
651
687
  `data.IRuntimeNetworkAddressPlan` is the address space an administrator gives the runtime network,
652
688
  and the single input Cloudly composes the protected authority from:
653
- `{ id: 'address-plan', revision, prefixes, pools, resolvers, platformEndpoints, vpn: { controlPrefix, relay } }`.
654
- Pools, resolvers and platform endpoints use the protected authority's own element shapes, and the
655
- relay uses the managed-VPN credential's `{ id, address, port }`. `platformEndpoints` never lists the
656
- relay: the composed authority adds it, in id order, as the UDP platform endpoint every node dials.
689
+ `{ id: 'address-plan', revision, prefixes, pools, resolvers, platformEndpoints, vpn: { controlPrefix, hubPrefix } }`.
690
+ Pools, resolvers and platform endpoints use the protected authority's own element shapes.
691
+ `controlPrefix` is the private prefix VPN control addresses are allocated from, and `hubPrefix` is
692
+ the protected prefix every cluster's VPN hub binds inside. The hub endpoints themselves are no
693
+ administrator's input: each cluster's relay reports what it bound, and the composition turns that
694
+ into platform endpoints, so `platformEndpoints` never lists one.
657
695
 
658
696
  `snapshotRuntimeNetworkAddressPlan` refuses any plan that could describe an authority the authority
659
- contract refuses. It runs `snapshotRuntimeNetworkProtectedAuthority` twice, once over the plan's
660
- own sets and once with the relay added, so overlapping pools, more than 16 resolvers, a resolver or
661
- endpoint outside `prefixes` or inside a pool, and a relay that `prefixes` do not cover are all
662
- refused by that one validator. On top it requires the VPN `controlPrefix` to be a private prefix no
663
- longer than /30 (`runtimeNetworkAddressPlanContract.maximumControlPrefixLength`) inside `prefixes`
664
- that overlaps no pool and holds no resolver or platform endpoint, the relay included. No minimum
665
- length is stated: the private-range rule already keeps it at /8 or longer.
666
- `composeRuntimeNetworkProtectedAuthority(plan, frame)` is the composition itself: the producer
697
+ contract refuses: it runs `snapshotRuntimeNetworkProtectedAuthority` over the plan's own sets, so
698
+ overlapping pools, more than 16 resolvers and a resolver or endpoint outside `prefixes` or inside a
699
+ pool are all refused by that one validator. On top it requires both VPN prefixes to sit inside
700
+ `prefixes`, to overlap no pool, to hold no resolver or platform endpoint, and never to overlap each
701
+ other — control addresses live inside the tunnel, hub endpoints on the underlay. `controlPrefix` is
702
+ additionally a private prefix no longer than /30
703
+ (`runtimeNetworkAddressPlanContract.maximumControlPrefixLength`); no minimum length is stated,
704
+ because the private-range rule already keeps it at /8 or longer.
705
+
706
+ `data.IRuntimeNetworkVpnHub { clusterId, address, quicPort, websocketPort }` is one cluster's hub as
707
+ its relay bound it, with `null` for a transport it did not bind and at least one of the two bound.
708
+ `runtimeNetworkVpnHubEndpoints(hub)` is the one derivation of what it contributes:
709
+ `<clusterId>:quic` on `udp` and `<clusterId>:ws` on `tcp`, in id order, as
710
+ `runtimeNetworkAddressPlanContract.hubTransports` states. Every reader calls it — the composition
711
+ that seals the authority and the router selection that names which endpoint a node may dial — so
712
+ two readers can never derive two ids for one hub.
713
+ `composeRuntimeNetworkProtectedAuthority(plan, frame, hubs)` is the composition itself: the producer
667
714
  supplies the frame (`TRuntimeNetworkProtectedAuthorityFrame`: authority id, generation, controller,
668
- previous reference and egress authorities) and receives the complete authority with its digest, so
669
- the validator and the protection producer can never compose differently.
715
+ previous reference and egress authorities) and the hubs of the clusters that registered one, and
716
+ receives the complete authority with its digest, so the validator and the protection producer can
717
+ never compose differently. Each hub's address must sit inside `vpn.hubPrefix` and one cluster is
718
+ named once; an empty `hubs` composes the authority of a runtime network whose clusters have no hub
719
+ yet. One hub costs up to two of the authority's 64 platform endpoints, so 32 clusters fit one
720
+ authority.
670
721
 
671
722
  `admitRuntimeNetworkAddressPlanChange(previous | null, next)` is the pure decision for a plan write at
672
723
  the following revision; any other revision is a contract violation. Leases and the managed VPN
673
724
  outlive a plan revision, so it answers:
674
725
 
675
726
  - `unchanged` when the content equals the stored plan; Cloudly writes nothing;
676
- - `refused` with `plan-vpn-immutable` when the VPN differs, `plan-pool-removed` when any pool is gone
727
+ - `refused` with `plan-vpn-immutable` when either VPN prefix differs, `plan-pool-removed` when any pool is gone
677
728
  or changed its purpose or prefix, and `plan-prefix-shrunk` when the new prefixes, however split,
678
729
  no longer cover every address the old ones did;
679
730
  - `accepted` with the ids of the resolvers and platform endpoints the change removes or changes. An
@@ -722,7 +773,19 @@ rule read the other way, for a receiver checking a list it was sent. Canonical i
722
773
  strictly increasing in the code-unit order of the names themselves — a rule a peer
723
774
  in another language implements from the vocabulary alone, rather than from the
724
775
  order this package happens to declare it in — and both directions compare through
725
- that one rule, so neither can drift from the other. All fields except the digest itself participate in a domain-separated
776
+ that one rule, so neither can drift from the other.
777
+
778
+ `workload.registryHost` is the registry that published the image, which is the publication identity
779
+ the image release is bound to, and it is never rewritten. `workload.pullEndpoint` is the optional
780
+ `host[:port]` the node fetches the bytes from instead — a cluster whose relay forwards the registry
781
+ states the relay's own origin, so a node pulls inside its cluster and nothing cluster-side dials the
782
+ control plane. `data.buildRuntimeAssignmentImageReference(workload)` is the one place that choice is
783
+ made: `pullEndpoint ?? registryHost`, digest-pinned either way, so the endpoint decides reachability
784
+ and never content, and a controller and a node cannot disagree about the reference. Absence stays
785
+ absence: an assignment that states no endpoint is different content, and so a different digest, from
786
+ one that does.
787
+
788
+ All fields except the digest itself participate in a domain-separated
726
789
  SHA-256 digest. Digests provide integrity and compare-and-swap identity;
727
790
  authentication must separately establish the trusted controller/node scope.
728
791
 
@@ -1666,6 +1729,54 @@ const registrationReasons = data.validateRegisterClusterRelayRequest(registratio
1666
1729
  How the relay reaches each node's Corestore is part of the same document: see
1667
1730
  [Corestore Through The Relay](#corestore-through-the-relay).
1668
1731
 
1732
+ ### Cluster VPN Hub And Network
1733
+
1734
+ A cluster's relay is also that cluster's managed VPN hub: every node of the cluster dials it, and
1735
+ an inter-node workload packet is relayed inside the cluster instead of crossing the control plane.
1736
+ Cloudly keeps every authority — it compiles the network and issues each node's credential — and
1737
+ carries no packet.
1738
+
1739
+ A relay that bound a hub says so in its registration, in `relay.vpnHub`:
1740
+ `data.IClusterRelayVpnHub { publicKey, address, quicPort, websocketPort }`. It reports what it
1741
+ bound, exactly like `listenAddress` and `listenPort`: `address` is an IPv4 literal inside the
1742
+ address plan's `vpn.hubPrefix`, a transport it did not bind is `null`, and at least one of the two
1743
+ is bound. `publicKey` is the hub's Noise public key in the one canonical 32-byte base64 encoding
1744
+ every Noise key of this contract is read with — this advertisement, the managed VPN credential's
1745
+ keys and a cluster network's nodes all pass the same reader. A relay names no endpoint id and no
1746
+ TLS name of its own: Cloudly composes the endpoints with `runtimeNetworkVpnHubEndpoints` (see
1747
+ [Runtime Network Address Plan](#runtime-network-address-plan)) and states the TLS name from the
1748
+ origin it published for that relay. The member is optional — a relay that serves no hub registers
1749
+ exactly as before, and Cloudly selects no hub for that cluster, stating `relay-vpn-hub-absent`.
1750
+ `validateClusterRelayVpnHub(value, path?)` returns one named reason per violation.
1751
+
1752
+ `data.IClusterVpnNetwork { authorityId, revision, nodes, grants }` is the whole managed VPN of one
1753
+ cluster. It is never a patch: a node or a grant the network does not carry is withdrawn, and
1754
+ membership is presence, so there is no enabled flag. Each node states `{ nodeId, publicKey,
1755
+ controlAddress, expiresAt, workloadPrefixes }`, where `expiresAt` is that node's credential expiry
1756
+ in Unix milliseconds and each prefix is `{ cidr, policyDomain }`. Each grant is one directed
1757
+ `{ sourceDomain, destinationDomain }` pair. `clusterVpnControlPolicyDomain(address)` and
1758
+ `clusterVpnWorkloadPolicyDomain(leaseDigest)` state the domain names once for both sides.
1759
+
1760
+ `snapshotClusterVpnNetwork` requires sorted, unique members, prefixes and grants, and requires
1761
+ every grant to join two workload domains the network itself carries, on two different nodes. That
1762
+ is what a per-cluster hub means: a grant whose other end sits on another cluster's hub cannot be
1763
+ expressed at all, which is why Cloudly refuses it while it compiles, by name, with
1764
+ `grant-cross-cluster-unsupported` from `data.clusterVpnNetworkSelectionRefusals`.
1765
+ `clusterVpnNetworkContract` bounds a network at 256 nodes, 128 workload prefixes per node, 4096
1766
+ grants and 917 504 canonical bytes.
1767
+
1768
+ `applyClusterVpnNetwork { network }` is Cloudly's push over the relay's already registered session;
1769
+ the relay answers `{ appliedRevision, state }`, where `state` is `idle`, `applying` or `failed` and
1770
+ `idle` means the pushed revision is the applied one — which is what
1771
+ `bindApplyClusterVpnNetworkResponse(response, sentRequest)` checks. `admitClusterVpnNetwork(held |
1772
+ null, next)` is the pure decision the relay makes against what its hub holds: `accepted` for a
1773
+ higher revision or an empty hub, `replay` for a repeat, and `refused` with
1774
+ `vpn-network-revision-stale` or `vpn-network-authority-foreign` from
1775
+ `data.applyClusterVpnNetworkRefusals`. `getClusterVpnNetwork` is the pull the other way, with no
1776
+ request body — the relay's socket names the cluster — answering `{ network }` or `{ network: null }`
1777
+ while the cluster has none. A hub daemon starts empty and its lifetime is the relay's own, so a
1778
+ relay that just bound one pulls instead of waiting for the next push.
1779
+
1669
1780
  ### Cluster Ingress
1670
1781
 
1671
1782
  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.3.0',
6
+ version: '32.5.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -5,6 +5,8 @@ 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 { isClusterNodeId } from '../private/nodeidentifier.js';
9
+ import { networkNoiseKey } from '../private/networkcontract.js';
8
10
 
9
11
  export interface ICluster {
10
12
  id: string;
@@ -62,6 +64,25 @@ export interface IClusterRelay {
62
64
  origin: string;
63
65
  }
64
66
 
67
+ /**
68
+ * The managed VPN hub a cluster relay bound for its own cluster.
69
+ *
70
+ * The relay is its cluster's packet relay: every node of the cluster dials this hub and no
71
+ * inter-node packet leaves the cluster. It reports what it bound, exactly like `listenAddress` and
72
+ * `listenPort`, and Cloudly composes the platform endpoints from it — a relay names no endpoint id
73
+ * and no TLS name of its own. `publicKey` is the hub's Noise public key in the one canonical
74
+ * 32-byte base64 encoding every Noise key of this contract is read with; it is public material and
75
+ * authenticates nothing by itself. A hub that bound only one transport reports the other port as
76
+ * `null`.
77
+ */
78
+ export interface IClusterRelayVpnHub {
79
+ publicKey: string;
80
+ /** The address the hub bound, as an IPv4 literal inside the address plan's `vpn.hubPrefix`. */
81
+ address: string;
82
+ quicPort: number | null;
83
+ websocketPort: number | null;
84
+ }
85
+
65
86
  /**
66
87
  * What a cluster relay tells Cloudly about itself once its socket is authenticated.
67
88
  *
@@ -88,6 +109,11 @@ export interface IRegisterClusterRelayRequest {
88
109
  */
89
110
  listenAddress: string;
90
111
  listenPort: number;
112
+ /**
113
+ * Absent on a relay that serves no managed VPN hub, which registers exactly as it did before.
114
+ * Cloudly selects no hub for such a cluster and states `relay-vpn-hub-absent` instead.
115
+ */
116
+ vpnHub?: IClusterRelayVpnHub;
91
117
  };
92
118
  /** Monotonic within one relay process; it restarts at 0 when the process restarts. */
93
119
  registrationSequence: number;
@@ -202,7 +228,7 @@ const validateClusterRelayVersion = (valueArg: unknown, pathArg: string): string
202
228
 
203
229
  /** The same identifier rule a node's own runtime session registers under. */
204
230
  const validateClusterRelayIdentifier = (valueArg: unknown, pathArg: string): string[] => (
205
- typeof valueArg === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(valueArg)
231
+ isClusterNodeId(valueArg)
206
232
  ? []
207
233
  : [`${pathArg} must be 1 to 128 characters of letters, digits, underscore or hyphen`]
208
234
  );
@@ -234,9 +260,56 @@ const validateClusterRelayNonNegativeInteger = (valueArg: unknown, pathArg: stri
234
260
  : [`${pathArg} must be a non-negative safe integer`]
235
261
  );
236
262
 
263
+ /**
264
+ * The one canonical 32-byte base64 reading every Noise key of this contract passes, stated as a
265
+ * refusal this validator's callers can collect: a hub key is the same key material the managed VPN
266
+ * credential carries, so it is never read by a second rule that could drift from it.
267
+ */
268
+ const validateClusterRelayVpnKey = (valueArg: unknown, pathArg: string): string[] => {
269
+ try {
270
+ networkNoiseKey(valueArg);
271
+ return [];
272
+ } catch {
273
+ return [`${pathArg} must be a canonical 32-byte base64 public key`];
274
+ }
275
+ };
276
+
277
+ /** A port the hub bound, or `null` for a transport it did not bind. */
278
+ const validateClusterRelayHubPort = (valueArg: unknown, pathArg: string): string[] => (
279
+ valueArg === null || validateClusterRelayListenPort(valueArg, pathArg).length === 0
280
+ ? []
281
+ : [`${pathArg} must be null or an integer from 1 to 65535`]
282
+ );
283
+
284
+ /**
285
+ * Validates one hub advertisement and names every reason it is refused.
286
+ *
287
+ * Shape only: whether the address sits inside the address plan's `vpn.hubPrefix` is Cloudly's to
288
+ * decide against the plan it stores, and the endpoints themselves are composed there.
289
+ */
290
+ export const validateClusterRelayVpnHub = (
291
+ valueArg: unknown,
292
+ pathArg = 'cluster relay vpn hub',
293
+ ): string[] => {
294
+ if (!isRecord(valueArg)
295
+ || !hasExactKeys(valueArg, ['publicKey', 'address', 'quicPort', 'websocketPort'])) {
296
+ return [`${pathArg} must use its exact schema`];
297
+ }
298
+ const errors = [
299
+ ...validateClusterRelayVpnKey(valueArg.publicKey, `${pathArg} publicKey`),
300
+ ...validateClusterRelayListenAddress(valueArg.address, `${pathArg} address`),
301
+ ...validateClusterRelayHubPort(valueArg.quicPort, `${pathArg} quicPort`),
302
+ ...validateClusterRelayHubPort(valueArg.websocketPort, `${pathArg} websocketPort`),
303
+ ];
304
+ if (valueArg.quicPort === null && valueArg.websocketPort === null) {
305
+ errors.push(`${pathArg} must bind at least one of quicPort and websocketPort`);
306
+ }
307
+ return errors;
308
+ };
309
+
237
310
  const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): string[] => {
238
311
  if (!isRecord(valueArg)
239
- || !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'])) {
312
+ || !hasExactKeys(valueArg, ['version', 'nodeId', 'listenAddress', 'listenPort'], ['vpnHub'])) {
240
313
  return [`${pathArg} must use its exact schema`];
241
314
  }
242
315
  return [
@@ -244,6 +317,9 @@ const validateClusterRelayEndpoint = (valueArg: unknown, pathArg: string): strin
244
317
  ...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
245
318
  ...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
246
319
  ...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
320
+ ...(Object.keys(valueArg).includes('vpnHub')
321
+ ? validateClusterRelayVpnHub(valueArg.vpnHub, `${pathArg} vpnHub`)
322
+ : []),
247
323
  ];
248
324
  };
249
325
 
@@ -2,6 +2,7 @@ import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
2
2
  import { isUtcCalendarDay } from '../private/utccalendarday.js';
3
3
  import { validateProtocolOfferOn } from '../protocol.js';
4
4
  import { hasExactKeys } from '../private/dataproperties.js';
5
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
5
6
 
6
7
  /**
7
8
  * The cluster ingress contracts.
@@ -310,11 +311,11 @@ export const validateClusterTrafficStatistics = (
310
311
  return [`${pathArg} must use its exact schema`];
311
312
  }
312
313
  const errors: string[] = [];
313
- for (const key of ['serviceId', 'nodeId'] as const) {
314
- const value = valueArg[key];
315
- if (typeof value !== 'string' || !identifierPattern.test(value)) {
316
- errors.push(`${pathArg} ${key} must be a bounded identifier`);
317
- }
314
+ if (typeof valueArg.serviceId !== 'string' || !identifierPattern.test(valueArg.serviceId)) {
315
+ errors.push(`${pathArg} serviceId must be a bounded identifier`);
316
+ }
317
+ if (!isClusterNodeId(valueArg.nodeId)) {
318
+ errors.push(`${pathArg} nodeId must be a node id`);
318
319
  }
319
320
  if (!isClusterIngressHostName(valueArg.hostName)) {
320
321
  errors.push(`${pathArg} hostName must be a lowercase dotted hostname`);
@@ -409,8 +410,8 @@ export const validateClusterIngressRegistration = (
409
410
  if (typeof valueArg.version !== 'string' || !identifierPattern.test(valueArg.version)) {
410
411
  errors.push(`${pathArg} version must be a bounded identifier`);
411
412
  }
412
- if (typeof valueArg.nodeId !== 'string' || !identifierPattern.test(valueArg.nodeId)) {
413
- errors.push(`${pathArg} nodeId must be a bounded identifier`);
413
+ if (!isClusterNodeId(valueArg.nodeId)) {
414
+ errors.push(`${pathArg} nodeId must be a node id`);
414
415
  }
415
416
  if (valueArg.capabilities !== undefined
416
417
  && (!Array.isArray(valueArg.capabilities)
@@ -11,6 +11,13 @@ import {
11
11
  readDataElements,
12
12
  readDataProperties,
13
13
  } from '../private/dataproperties.js';
14
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
15
+
16
+ /**
17
+ * The vocabulary every node id in this package is read in, published here beside `IClusterNode`
18
+ * so a service that stores or forwards a node id judges it exactly as these contracts do.
19
+ */
20
+ export { isClusterNodeId };
14
21
 
15
22
  export interface IClusterNodeMetrics {
16
23
  cpuUsagePercent: number;
@@ -625,8 +632,8 @@ const validateSparkReporterIdentity = (
625
632
  labelArg: string,
626
633
  ): string[] => {
627
634
  const errors: string[] = [];
628
- if (!isRuntimeIdentifier(requestArg.nodeId)) {
629
- errors.push(`${labelArg} nodeId must be canonical`);
635
+ if (!isClusterNodeId(requestArg.nodeId)) {
636
+ errors.push(`${labelArg} nodeId must be a node id`);
630
637
  }
631
638
  if (typeof requestArg.nodeToken !== 'string'
632
639
  || requestArg.nodeToken.length < 1
@@ -922,6 +929,11 @@ const createSparkFieldErrors = (
922
929
  errors.push(`${labelArg} ${keyArg} must be canonical`);
923
930
  }
924
931
  },
932
+ nodeIdentifier: (keyArg: string): void => {
933
+ if (!isClusterNodeId(recordArg[keyArg])) {
934
+ errors.push(`${labelArg} ${keyArg} must be a node id`);
935
+ }
936
+ },
925
937
  boolean: (keyArg: string): void => {
926
938
  if (typeof recordArg[keyArg] !== 'boolean') {
927
939
  errors.push(`${labelArg} ${keyArg} must be a boolean`);
@@ -1054,7 +1066,7 @@ const validateSparkNodeRuntimeInfo = (valueArg: unknown, labelArg: string): stri
1054
1066
  if (runtimeInfo.runtime !== 'spark') {
1055
1067
  field.errors.push(`${labelArg} runtime must be spark`);
1056
1068
  }
1057
- field.identifier('nodeId');
1069
+ field.nodeIdentifier('nodeId');
1058
1070
  field.text('platform');
1059
1071
  field.text('arch');
1060
1072
  field.text('sparkVersion');
@@ -1118,6 +1130,17 @@ export const validateSparkSwarmNodeHeartbeatRequest = async (
1118
1130
  envelope.record.runtimeInfo,
1119
1131
  `${label} runtimeInfo`,
1120
1132
  ));
1133
+ // One body, one node. The bearer authenticates the sender named by `nodeId`, so a runtime info
1134
+ // that names another node would report that node's Docker, Cloudly connection and services
1135
+ // under a credential it never held. Only the pair is checked here: an unreadable member of
1136
+ // either has already named itself above.
1137
+ const runtimeInfo = envelope.record.runtimeInfo;
1138
+ if (isClusterNodeId(envelope.record.nodeId)
1139
+ && isRuntimeRecord(runtimeInfo)
1140
+ && isClusterNodeId(runtimeInfo.nodeId)
1141
+ && runtimeInfo.nodeId !== envelope.record.nodeId) {
1142
+ envelope.errors.push(`${label} runtimeInfo nodeId must name the request nodeId`);
1143
+ }
1121
1144
  return envelope.errors;
1122
1145
  } catch {
1123
1146
  return [`${label} must be safely inspectable`];
@@ -1290,7 +1313,6 @@ export const validateClusterRuntimeTargetSet = async (
1290
1313
  'nodeName',
1291
1314
  'platform',
1292
1315
  ])
1293
- || !isRuntimeIdentifier(targetArg.cloudlyNodeId)
1294
1316
  || !isRuntimeIdentifier(targetArg.swarmClusterId)
1295
1317
  || !isRuntimeIdentifier(targetArg.swarmNodeId)
1296
1318
  || !isRuntimeIdentifier(targetArg.nodeName)
@@ -1299,6 +1321,12 @@ export const validateClusterRuntimeTargetSet = async (
1299
1321
  errors.push(`cluster runtime target set targets[${index}] must be canonical`);
1300
1322
  continue;
1301
1323
  }
1324
+ // The node this target names is judged in the node vocabulary, so it states that and not
1325
+ // the canonical rule the Swarm identities beside it are held to.
1326
+ if (!isClusterNodeId(targetArg.cloudlyNodeId)) {
1327
+ errors.push(`cluster runtime target set targets[${index}].cloudlyNodeId must be a node id`);
1328
+ continue;
1329
+ }
1302
1330
  const sortKey = `${targetArg.cloudlyNodeId}\0${targetArg.swarmClusterId}\0${
1303
1331
  targetArg.swarmNodeId
1304
1332
  }\0${targetArg.nodeName}\0${targetArg.platform}`;