@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
@@ -1,9 +1,11 @@
1
1
  import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
2
+ import { runtimeNetworkAddressPlanContract, type TRuntimeNetworkVpnTransport } from './networkaddressplan.js';
2
3
  import { snapshotRuntimeSessionBinding, type IRuntimeSessionBinding } from './runtimesession.js';
3
4
  import { validateRuntimeNetworkProjection, type IRuntimeNetworkProjection } from './networkprojection.js';
4
5
  import { snapshotRuntimeNetworkDnsLeaseRenewal, validateRuntimeNetworkDnsLeaseRenewal } from './networklease.js';
5
6
  import { captureNetwork, failNetwork, networkCounter, networkEffectiveDnsWindow, networkEqual, networkId, networkIpv4,
6
- networkRecord, networkReference, networkReferenceOf, networkSafe, networkSameScope, requireNetwork } from '../private/networkcontract.js';
7
+ networkNoiseKey, networkRecord, networkReference, networkReferenceOf, networkSafe, networkSameScope,
8
+ requireNetwork } from '../private/networkcontract.js';
7
9
 
8
10
  export interface IGetRuntimeManagedVpnCredentialRequest {
9
11
  session: IRuntimeSessionBinding;
@@ -14,8 +16,16 @@ export interface IGetRuntimeManagedVpnCredentialRequest {
14
16
  * persist, hash, log, or include it in a signed network projection. The native
15
17
  * Noise owner validates and uses the keys; these shapes confer no TUN readiness. */
16
18
  export interface IGetRuntimeManagedVpnCredentialResponse extends IGetRuntimeManagedVpnCredentialRequest {
17
- transport: 'quic';
18
- relay: { id: string; address: string; port: number };
19
+ transport: TRuntimeNetworkVpnTransport;
20
+ /** The cluster hub endpoint this node dials, named by the id its signed projection selected. */
21
+ hub: { id: string; address: string; port: number };
22
+ /**
23
+ * The TLS name the hub's WebSocket transport is dialled under, present exactly when `transport`
24
+ * is `websocket` and absent otherwise: QUIC is dialled on the address alone. It is the hub
25
+ * relay's own origin hostname, which the node cross-checks against the relay origin it stores,
26
+ * so a name the node does not already trust ends the handshake before any native effect.
27
+ */
28
+ serverName?: string;
19
29
  authorityId: string;
20
30
  serverPublicKey: string;
21
31
  clientPublicKey: string;
@@ -27,28 +37,44 @@ export interface IGetRuntimeManagedVpnCredentialResponse extends IGetRuntimeMana
27
37
  expiresAt: number;
28
38
  }
29
39
 
30
- /** Canonical 32-byte key encoding only. Cryptographic key agreement and pair
31
- * validation belong to the native Noise implementation, not this JSON parser. */
32
- const key = (value: unknown): string => {
33
- requireNetwork(typeof value === 'string' && /^[A-Za-z0-9+/]{43}=$/.test(value));
34
- const decoded = atob(value);
35
- requireNetwork(decoded.length === 32 && btoa(decoded) === value && [...decoded].some(byte => byte.charCodeAt(0) !== 0));
36
- return value;
37
- };
38
-
39
40
  export const snapshotGetRuntimeManagedVpnCredentialRequest = (value: unknown): IGetRuntimeManagedVpnCredentialRequest => networkSafe(() => {
40
41
  const item = networkRecord(captureNetwork(value), ['session', 'projection']);
41
42
  return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection) };
42
43
  });
43
44
 
45
+ /**
46
+ * The TLS name a WebSocket hub is dialled under: a lower-case domain name of at least two labels,
47
+ * which is what a relay origin's hostname is and what a certificate can be issued for. An address
48
+ * literal is not one, so a node can never be sent to a name nothing vouches for.
49
+ */
50
+ const serverName = (value: unknown): string => {
51
+ requireNetwork(typeof value === 'string' && value.length <= 253);
52
+ const labels = (value as string).split('.');
53
+ // The last label starts with a letter, which is what makes this a name and not an address
54
+ // literal: a hub reached by address is reached over QUIC, which needs no name at all.
55
+ requireNetwork(labels.length >= 2 && labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
56
+ && /^[a-z]/.test(labels[labels.length - 1]));
57
+ return value as string;
58
+ };
59
+
60
+ const transportOf = (value: unknown): TRuntimeNetworkVpnTransport => {
61
+ const row = runtimeNetworkAddressPlanContract.hubTransports.find(candidate => candidate.transport === value);
62
+ requireNetwork(row); return row.transport;
63
+ };
64
+
44
65
  export const snapshotGetRuntimeManagedVpnCredentialResponse = (value: unknown): IGetRuntimeManagedVpnCredentialResponse => networkSafe(() => {
45
- const item = networkRecord(captureNetwork(value), ['session', 'projection', 'transport', 'relay', 'authorityId',
46
- 'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt']);
47
- const relay = networkRecord(item.relay, ['id', 'address', 'port']); requireNetwork(item.transport === 'quic');
48
- return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection), transport: 'quic',
49
- relay: { id: networkId(relay.id), address: networkIpv4(relay.address), port: networkCounter(relay.port, 65535) },
50
- authorityId: networkId(item.authorityId), serverPublicKey: key(item.serverPublicKey), clientPublicKey: key(item.clientPublicKey),
51
- clientPrivateKey: key(item.clientPrivateKey), expiresAt: networkCounter(item.expiresAt) };
66
+ const item = networkRecord(captureNetwork(value), ['session', 'projection', 'transport', 'hub', 'authorityId',
67
+ 'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt'], ['serverName']);
68
+ const hub = networkRecord(item.hub, ['id', 'address', 'port']), transport = transportOf(item.transport);
69
+ // A name belongs to the one transport that is dialled by name; stating it for QUIC would name a
70
+ // trust the node never checks, and omitting it for WebSocket leaves the TLS identity unstated.
71
+ requireNetwork(Object.hasOwn(item, 'serverName') === (transport === 'websocket'));
72
+ return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection), transport,
73
+ hub: { id: networkId(hub.id), address: networkIpv4(hub.address), port: networkCounter(hub.port, 65535) },
74
+ ...(transport === 'websocket' ? { serverName: serverName(item.serverName) } : {}),
75
+ authorityId: networkId(item.authorityId), serverPublicKey: networkNoiseKey(item.serverPublicKey),
76
+ clientPublicKey: networkNoiseKey(item.clientPublicKey), clientPrivateKey: networkNoiseKey(item.clientPrivateKey),
77
+ expiresAt: networkCounter(item.expiresAt) };
52
78
  });
53
79
 
54
80
  const bindRequest = (request: IGetRuntimeManagedVpnCredentialRequest,
@@ -70,8 +96,8 @@ export const bindGetRuntimeManagedVpnCredentialRequest = async (value: unknown,
70
96
  } catch { return failNetwork(); }
71
97
  };
72
98
 
73
- /** Bind the secret response to the original request and exact selected router
74
- * UDP endpoint, inside the projection's effective DNS window. renewalValue is the
99
+ /** Bind the secret response to the original request and the exact selected router
100
+ * endpoint of the credential's transport, inside the projection's effective DNS window. renewalValue is the
75
101
  * bare lease renewal the caller already verified and admitted for this exact
76
102
  * projection, or null; this helper verifies no signature. Time is caller-qualified;
77
103
  * this helper does not sample a clock, authenticate a transport, admit policy or
@@ -89,9 +115,11 @@ export const bindGetRuntimeManagedVpnCredentialResponse = async (value: unknown,
89
115
  const window = networkEffectiveDnsWindow(projection, renewal);
90
116
  bindRequest(request, projection, trusted);
91
117
  requireNetwork(networkEqual(response.session, request.session) && networkEqual(response.projection, request.projection));
92
- requireNetwork(projection.router?.platformEndpointIds.includes(response.relay.id));
93
- const endpoint = projection.protectedAuthority.platformEndpoints.find(item => item.id === response.relay.id);
94
- requireNetwork(endpoint && endpoint.protocol === 'udp' && endpoint.address === response.relay.address && endpoint.port === response.relay.port);
118
+ requireNetwork(projection.router?.platformEndpointIds.includes(response.hub.id));
119
+ const endpoint = projection.protectedAuthority.platformEndpoints.find(item => item.id === response.hub.id);
120
+ const expected = runtimeNetworkAddressPlanContract.hubTransports.find(row => row.transport === response.transport);
121
+ requireNetwork(endpoint && expected && endpoint.protocol === expected.protocol
122
+ && endpoint.address === response.hub.address && endpoint.port === response.hub.port);
95
123
  requireNetwork(window.notBefore <= now && now < response.expiresAt && response.expiresAt <= window.expiresAt);
96
124
  return response;
97
125
  } catch { return failNetwork(); }
@@ -3,7 +3,7 @@ import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
3
3
  import type { IRuntimeNetworkDnsWindow, IRuntimeNetworkNodeScope } from './networkprojection.js';
4
4
  import { snapshotRuntimeNetworkNodeScope, snapshotRuntimeNetworkProjection, validateRuntimeNetworkProjection } from './networkprojection.js';
5
5
  import { captureNetwork, failNetwork, networkCounter, networkDigest, networkDnsWindow, networkEffectiveDnsWindow, networkEqual,
6
- networkHash, networkId, networkList, networkMaximumDnsLeaseMs, networkRecord, networkReference, networkReferenceOf,
6
+ networkHash, networkId, networkList, networkMaximumDnsLeaseMs, networkNodeId, networkRecord, networkReference, networkReferenceOf,
7
7
  networkRenewsProjection, networkSafe, networkSameScope, networkScope, networkSignature, requireNetwork } from '../private/networkcontract.js';
8
8
 
9
9
  /**
@@ -223,7 +223,7 @@ const heldHorizonMs = (renewals: ISignedRuntimeNetworkDnsLeaseRenewal[]): number
223
223
  export const snapshotHoldRuntimeNetworkDnsLeaseRenewalsRequest = (value: unknown): IHoldRuntimeNetworkDnsLeaseRenewalsRequest => networkSafe(() => {
224
224
  const item = networkRecord(captureNetwork(value), ['nodeId', 'projection', 'renewals']);
225
225
  const contract = runtimeNetworkDnsLeaseRenewalContract;
226
- const request: IHoldRuntimeNetworkDnsLeaseRenewalsRequest = { nodeId: networkId(item.nodeId), projection: networkReference(item.projection),
226
+ const request: IHoldRuntimeNetworkDnsLeaseRenewalsRequest = { nodeId: networkNodeId(item.nodeId), projection: networkReference(item.projection),
227
227
  renewals: networkList(item.renewals, contract.maximumEscrowedRenewals, snapshotSignedRuntimeNetworkDnsLeaseRenewal) };
228
228
  request.renewals.forEach((signed, index) => requireNetwork(signed.renewal.nodeId === request.nodeId
229
229
  && networkEqual(signed.renewal.projection, request.projection)
@@ -1,5 +1,5 @@
1
1
  import type { IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
2
- import { captureNetwork, networkCounter, networkId, networkRecord, networkSafe } from '../private/networkcontract.js';
2
+ import { captureNetwork, networkCounter, networkNodeId, networkRecord, networkSafe } from '../private/networkcontract.js';
3
3
 
4
4
  type TEgressOwner = IRuntimeNetworkProtectedAuthority['egressAuthorities'][number];
5
5
 
@@ -33,5 +33,5 @@ export interface IRuntimeRouterIncarnation {
33
33
 
34
34
  export const snapshotRuntimeRouterIncarnation = (value: unknown): IRuntimeRouterIncarnation => networkSafe(() => {
35
35
  const item = networkRecord(captureNetwork(value), ['id', 'incarnation']);
36
- return { id: networkId(item.id), incarnation: networkCounter(item.incarnation) };
36
+ return { id: networkNodeId(item.id), incarnation: networkCounter(item.incarnation) };
37
37
  });
@@ -1,6 +1,6 @@
1
1
  import type { TSha256Digest } from './immutableimage.js';
2
2
  import type { IRuntimeAssignmentReference, IRuntimeAuthorityReference, IRuntimeController } from './runtimeassignment.js';
3
- import { snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
3
+ import { isRuntimeReplicaId, snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
4
4
  import type { IRuntimeNetworkHandoffLease, IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
5
5
  import { bindRuntimeNetworkHandoffLease, computeRuntimeNetworkProtectedAuthorityDigest,
6
6
  snapshotRuntimeNetworkHandoffLease, snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
@@ -191,6 +191,10 @@ export const runtimeNetworkProjectionContract = Object.freeze({
191
191
  allocationReuse: 'requires-controller-quarantine-and-all-affected-authority-fences',
192
192
  } as const);
193
193
 
194
+ /** The assignment contract owns the replica rule; a lease names the same slot by the same name. */
195
+ const replicaId = (value: unknown): string => {
196
+ requireNetwork(isRuntimeReplicaId(value)); return value as string;
197
+ };
194
198
  const ownerKey = (value: { organizationId: string; serviceId: string }): string => networkCanonical([value.organizationId, value.serviceId]);
195
199
  const attemptKey = (value: IRuntimeNetworkWorkloadLease): string =>
196
200
  networkCanonical([value.organizationId, value.serviceId, value.replicaId, value.attemptId]);
@@ -220,7 +224,7 @@ export const snapshotRuntimeNetworkWorkloadLease = (value: unknown): IRuntimeNet
220
224
  requireNetwork(item.dnsServer === gateway);
221
225
  return { id: networkId(item.id), generation: networkCounter(item.generation), ...networkScope(item),
222
226
  organizationId: networkId(item.organizationId), assignmentId: networkId(item.assignmentId), serviceId: networkId(item.serviceId),
223
- replicaId: networkId(item.replicaId), attemptId: networkId(item.attemptId), protectedAuthority: networkReference(item.protectedAuthority),
227
+ replicaId: replicaId(item.replicaId), attemptId: networkId(item.attemptId), protectedAuthority: networkReference(item.protectedAuthority),
224
228
  routerIncarnationId: networkId(item.routerIncarnationId), ipv4: { poolId: networkId(ip.poolId), linkSubnet: ip.linkSubnet,
225
229
  address, sourcePrefix: ip.sourcePrefix, gateway }, dnsServer: gateway, digest: networkDigest(item.digest) };
226
230
  });
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  readDataElements, readDataProperty, readExactDataRecord,
10
10
  } from '../private/dataproperties.js';
11
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
11
12
 
12
13
  export interface INodeEnrollmentCredentialProposal {
13
14
  purpose: TNodeCredentialPurpose;
@@ -139,9 +140,10 @@ const pair = (valueArg: unknown): [unknown, unknown] => {
139
140
  requireValue(elements !== null && elements.length === 2);
140
141
  return [elements[0], elements[1]];
141
142
  };
143
+ /** Enrollment names the node, its installations and its mutations in the node's own vocabulary. */
142
144
  const id = (valueArg: unknown): string => {
143
- requireValue(typeof valueArg === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(valueArg));
144
- return valueArg;
145
+ requireValue(isClusterNodeId(valueArg));
146
+ return valueArg as string;
145
147
  };
146
148
  const hash = (valueArg: unknown): string => {
147
149
  requireValue(typeof valueArg === 'string' && /^[a-f0-9]{64}$/.test(valueArg));
@@ -1,4 +1,4 @@
1
- import { captureNetwork, networkCounter, networkId, networkRecord, requireNetwork } from '../private/networkcontract.js';
1
+ import { captureNetwork, networkCounter, networkId, networkNodeId, networkRecord, requireNetwork } from '../private/networkcontract.js';
2
2
  import { isOneOf, producerSafe } from '../private/producercontract.js';
3
3
 
4
4
  const nodeRuntimeRetirementStates = ['retiring', 'retired'] as const;
@@ -58,6 +58,6 @@ export type TNodeRuntimeRetirementRefusal = typeof nodeRuntimeRetirementRefusals
58
58
  export const snapshotNodeRuntimeRetirement = (value: unknown): INodeRuntimeRetirement => producerSafe(() => {
59
59
  const item = networkRecord(captureNetwork(value), ['id', 'clusterId', 'state', 'actorId', 'since']);
60
60
  requireNetwork(isOneOf(nodeRuntimeRetirementStates, item.state));
61
- return { id: networkId(item.id), clusterId: networkId(item.clusterId), state: item.state,
61
+ return { id: networkNodeId(item.id), clusterId: networkId(item.clusterId), state: item.state,
62
62
  actorId: networkId(item.actorId), since: networkCounter(item.since) };
63
63
  });
@@ -3,6 +3,7 @@ import { buildDigestPinnedImageReference, isSha256Digest } from './immutableimag
3
3
  import {
4
4
  canonicalizeStrictJson, compareWireTokens, createCanonicalJsonSha256Hex,
5
5
  } from '../private/canonicaljson.js';
6
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
6
7
  import { isCanonicalTokenList } from '../private/tokenlist.js';
7
8
 
8
9
  export interface IRuntimeController {
@@ -55,7 +56,20 @@ export interface IRuntimeAssignment extends IRuntimeAssignmentScope {
55
56
  /** No automatic relocation or expiry-based claim of a stopped writer. */
56
57
  offlinePolicy: 'continue-node-bound';
57
58
  workload: {
59
+ /**
60
+ * The registry that published this image, which is the publication identity the image release
61
+ * is bound to. It is never rewritten: only `pullEndpoint` decides where the bytes are fetched.
62
+ */
58
63
  registryHost: string;
64
+ /**
65
+ * Where this node fetches the image instead of `registryHost`, as `host[:port]`.
66
+ *
67
+ * A cluster whose relay forwards the registry states the relay's own origin here, so a node
68
+ * pulls inside its cluster and nothing cluster-side dials the control plane. Absent when the
69
+ * node pulls from the publishing registry itself. The reference stays digest-pinned either
70
+ * way, so the endpoint decides reachability and never content.
71
+ */
72
+ pullEndpoint?: string;
59
73
  repository: string;
60
74
  imageDigest: TSha256Digest;
61
75
  platform: TImmutableContainerPlatform;
@@ -99,7 +113,7 @@ const workloadAuthorityDemands: Readonly<Record<
99
113
  export const requiredWorkloadAuthorities = (
100
114
  workloadArg: IRuntimeAssignment['workload'],
101
115
  ): TRuntimeWorkloadAuthority[] => {
102
- if (!isExactRecord(workloadArg, workloadKeys) || !Array.isArray(workloadArg.storage)) {
116
+ if (!isExactRecord(workloadArg, workloadKeys, optionalWorkloadKeys) || !Array.isArray(workloadArg.storage)) {
103
117
  fail(workloadSchemaMessage);
104
118
  }
105
119
  return runtimeWorkloadAuthorities
@@ -107,6 +121,30 @@ export const requiredWorkloadAuthorities = (
107
121
  .sort(compareWireTokens);
108
122
  };
109
123
 
124
+ /**
125
+ * The digest-pinned image reference a node pulls this workload by.
126
+ *
127
+ * One function decides where the bytes come from: the stated `pullEndpoint`, else the publishing
128
+ * `registryHost`. The digest is the same either way, so a node that pulls through its cluster's
129
+ * relay fetches exactly the content the publishing registry named, and a controller and a node can
130
+ * never disagree about the reference. The workload is read as a wire value, so one whose members
131
+ * this contract refuses is refused here rather than by a member read.
132
+ */
133
+ export const buildRuntimeAssignmentImageReference = (
134
+ workloadArg: IRuntimeAssignment['workload'],
135
+ ): string => {
136
+ if (!isExactRecord(workloadArg, workloadKeys, optionalWorkloadKeys)) {
137
+ fail(workloadSchemaMessage);
138
+ }
139
+ const endpoint = Object.hasOwn(workloadArg, 'pullEndpoint')
140
+ ? workloadArg.pullEndpoint : workloadArg.registryHost;
141
+ if (typeof endpoint !== 'string' || typeof workloadArg.repository !== 'string'
142
+ || !isDigest(workloadArg.imageDigest)) {
143
+ fail(workloadSchemaMessage);
144
+ }
145
+ return buildDigestPinnedImageReference(endpoint, workloadArg.repository, workloadArg.imageDigest);
146
+ };
147
+
110
148
  /** True when a value is exactly a canonical authority list drawn from the authority vocabulary. */
111
149
  export const isResolvedWorkloadAuthorities = (
112
150
  valueArg: unknown,
@@ -147,23 +185,50 @@ export const runtimeAssignmentContract = Object.freeze({
147
185
  const fail: (messageArg: string) => never = (messageArg) => { throw new Error(messageArg); };
148
186
  const isIdentifier = (valueArg: unknown): valueArg is string =>
149
187
  typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(valueArg);
188
+ /** The index of a replica slot: counted from zero by its producer, never zero-padded. */
189
+ const isReplicaIndex = (valueArg: string): boolean => /^(?:0|[1-9][0-9]{0,8})$/.test(valueArg);
190
+
191
+ /**
192
+ * True when a value is exactly a replica id.
193
+ *
194
+ * A producer names a replica slot after the node that runs it — `<node id>.<index>`, counted from
195
+ * zero — so a replica id that carries an index is read by decomposition: the part before the last
196
+ * dot in the node vocabulary, the index on its own. Reading the composed form as one canonical
197
+ * identifier would refuse every replica of a node whose id begins with `-` or `_`, a node these
198
+ * contracts themselves enrol and register. A name that carries no such index — a zero-padded index
199
+ * among them — is an opaque slot name and is read whole in the canonical identifier vocabulary, as
200
+ * it always was.
201
+ */
202
+ export const isRuntimeReplicaId = (valueArg: unknown): valueArg is string => {
203
+ if (typeof valueArg !== 'string') return false;
204
+ const separator = valueArg.lastIndexOf('.');
205
+ return separator > 0 && isReplicaIndex(valueArg.slice(separator + 1))
206
+ ? isClusterNodeId(valueArg.slice(0, separator))
207
+ : isIdentifier(valueArg);
208
+ };
150
209
  const isPositiveInteger = (valueArg: unknown): valueArg is number =>
151
210
  typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg > 0;
152
211
  const isDigest = (valueArg: unknown): valueArg is TSha256Digest =>
153
212
  typeof valueArg === 'string' && isSha256Digest(valueArg);
154
213
 
155
- /** Reject accessors before reading any fields; do not run input-owned code. */
214
+ /**
215
+ * Reject accessors before reading any fields; do not run input-owned code.
216
+ *
217
+ * Absence stays absence: a stated optional key is read, an omitted one is not defaulted, so a
218
+ * controller that stated nothing is distinguishable from one that stated a value.
219
+ */
156
220
  const isExactRecord = (
157
221
  valueArg: unknown,
158
222
  keysArg: readonly string[],
223
+ optionalKeysArg: readonly string[] = [],
159
224
  ): valueArg is Record<string, unknown> => {
160
225
  if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
161
226
  const prototype = Object.getPrototypeOf(valueArg);
162
227
  if (prototype !== Object.prototype && prototype !== null) return false;
163
228
  const keys = Reflect.ownKeys(valueArg);
164
- return keys.length === keysArg.length && keys.every((keyArg) => {
229
+ return keysArg.every((keyArg) => keys.includes(keyArg)) && keys.every((keyArg) => {
165
230
  const descriptor = Object.getOwnPropertyDescriptor(valueArg, keyArg);
166
- return typeof keyArg === 'string' && keysArg.includes(keyArg)
231
+ return typeof keyArg === 'string' && (keysArg.includes(keyArg) || optionalKeysArg.includes(keyArg))
167
232
  && descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value');
168
233
  });
169
234
  };
@@ -183,6 +248,8 @@ const isAssignmentReference = (valueArg: unknown): valueArg is IRuntimeAssignmen
183
248
  /** The workload's exact members, read by the authority vocabulary and by the snapshot alike. */
184
249
  const workloadKeys = ['registryHost', 'repository', 'imageDigest', 'platform',
185
250
  'config', 'network', 'secrets', 'storage'] as const;
251
+ /** Stated only by a controller that sends the node somewhere else for the image bytes. */
252
+ const optionalWorkloadKeys = ['pullEndpoint'] as const;
186
253
  const workloadSchemaMessage = 'runtime assignment workload must use exact immutable references';
187
254
 
188
255
  const assignmentKeys = [
@@ -199,10 +266,11 @@ const assertAssignment = (valueArg: unknown): IRuntimeAssignment => {
199
266
  || !['run', 'stop-preserve', 'remove-runtime-preserve-storage'].includes(
200
267
  valueArg.disposition as string,
201
268
  )) fail('runtime assignment must use its exact schema');
202
- for (const key of ['organizationId', 'nodeId', 'runtimeNamespace', 'assignmentId',
203
- 'serviceId', 'replicaId', 'attemptId']) {
269
+ for (const key of ['organizationId', 'runtimeNamespace', 'assignmentId', 'serviceId', 'attemptId']) {
204
270
  if (!isIdentifier(valueArg[key])) fail(`runtime assignment ${key} must be a canonical identifier`);
205
271
  }
272
+ if (!isClusterNodeId(valueArg.nodeId)) fail('runtime assignment nodeId must be a node id');
273
+ if (!isRuntimeReplicaId(valueArg.replicaId)) fail('runtime assignment replicaId must be a replica id');
206
274
  for (const key of ['workerGroupId', 'siteId']) {
207
275
  if (valueArg[key] !== null && !isIdentifier(valueArg[key])) fail('invalid runtime topology ID');
208
276
  }
@@ -213,8 +281,10 @@ const assertAssignment = (valueArg: unknown): IRuntimeAssignment => {
213
281
  fail('runtime assignment must bind its exact preceding generation');
214
282
  }
215
283
  const workload = valueArg.workload;
216
- if (!isExactRecord(workload, workloadKeys)
284
+ if (!isExactRecord(workload, workloadKeys, optionalWorkloadKeys)
217
285
  || typeof workload.registryHost !== 'string' || workload.registryHost.length > 253
286
+ || (Object.hasOwn(workload, 'pullEndpoint')
287
+ && (typeof workload.pullEndpoint !== 'string' || workload.pullEndpoint.length > 253))
218
288
  || typeof workload.repository !== 'string' || workload.repository.length > 255
219
289
  || !isDigest(workload.imageDigest)
220
290
  || !['linux/amd64', 'linux/arm64'].includes(workload.platform as string)
@@ -227,6 +297,12 @@ const assertAssignment = (valueArg: unknown): IRuntimeAssignment => {
227
297
  }
228
298
  buildDigestPinnedImageReference(workload.registryHost as string,
229
299
  workload.repository as string, workload.imageDigest as TSha256Digest);
300
+ // The pull endpoint is an OCI registry host like the publishing one, so the same reference the
301
+ // node builds proves it: an endpoint no reference can name is refused where it is written.
302
+ if (Object.hasOwn(workload, 'pullEndpoint')) {
303
+ buildDigestPinnedImageReference(workload.pullEndpoint as string,
304
+ workload.repository as string, workload.imageDigest as TSha256Digest);
305
+ }
230
306
  let previousId = '';
231
307
  for (let index = 0; index < (workload.storage as unknown[]).length; index++) {
232
308
  const descriptor = Object.getOwnPropertyDescriptor(workload.storage, String(index));
@@ -304,7 +380,7 @@ export const evaluateRuntimeAssignment = async (
304
380
  acceptedArg = acceptedArg === null ? null : snapshotRuntimeAssignment(acceptedArg);
305
381
  if (!isExactRecord(trustedScopeArg, ['controller', 'organizationId', 'nodeId', 'runtimeNamespace'])
306
382
  || !isController(trustedScopeArg.controller)
307
- || !isIdentifier(trustedScopeArg.organizationId) || !isIdentifier(trustedScopeArg.nodeId)
383
+ || !isIdentifier(trustedScopeArg.organizationId) || !isClusterNodeId(trustedScopeArg.nodeId)
308
384
  || !isIdentifier(trustedScopeArg.runtimeNamespace)) return { status: 'reject', reason: 'invalid' };
309
385
  trustedScopeArg = JSON.parse(canonical(trustedScopeArg)) as IRuntimeAssignmentScope;
310
386
  } catch {
@@ -12,6 +12,7 @@ import { negotiateProtocol, readProtocolOffer, type IProtocolOffer } from '../pr
12
12
  import type { IRuntimeConfig } from './runtimeconfig.js';
13
13
  import { bindRuntimeConfigToAssignment, snapshotRuntimeConfig } from './runtimeconfig.js';
14
14
  import { canonicalizeStrictJson } from '../private/canonicaljson.js';
15
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
15
16
 
16
17
  /**
17
18
  * Server-issued public binding for the current authenticated physical connection.
@@ -178,8 +179,8 @@ const identifier = (valueArg: unknown): string => {
178
179
  return valueArg;
179
180
  };
180
181
  const nodeId = (valueArg: unknown): string => {
181
- requireValue(typeof valueArg === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(valueArg));
182
- return valueArg;
182
+ requireValue(isClusterNodeId(valueArg));
183
+ return valueArg as string;
183
184
  };
184
185
  /** One node process, in the same canonical form as the native boot UUID. */
185
186
  const nodeInstanceNonce = (valueArg: unknown): string => {
@@ -3,7 +3,7 @@ import type {
3
3
  } from './runtimeassignment.js';
4
4
  import type { IRuntimeConfigLogPolicy, TRuntimeConfigReadiness } from './runtimeconfig.js';
5
5
  import { runtimeNetworkAuthorityContract } from './networkauthority.js';
6
- import { captureNetwork, networkCanonical, networkCounter, networkId, networkList, networkRecord, networkSorted,
6
+ import { captureNetwork, networkCanonical, networkCounter, networkId, networkList, networkNodeId, networkRecord, networkSorted,
7
7
  requireNetwork } from '../private/networkcontract.js';
8
8
  import { failProducer, isOneOf, producerSafe, variantOf } from '../private/producercontract.js';
9
9
  import { assertRuntimeReadiness, isBoundedInteger, isRuntimeCpuMillis, isRuntimeIdentityNumber, isRuntimeLogPolicy,
@@ -138,8 +138,9 @@ export interface IServiceRuntimeAssignmentView {
138
138
  delivery: TServiceRuntimeAssignmentDelivery;
139
139
  }
140
140
 
141
- const sortedIds = (value: unknown, maximum: number): string[] =>
142
- networkSorted(networkList(value, maximum, networkId), id => id);
141
+ /** Sorted, duplicate-free and read by the vocabulary the ids belong to: a node's, or the canonical one. */
142
+ const sortedIds = (value: unknown, maximum: number, read: (item: unknown) => string): string[] =>
143
+ networkSorted(networkList(value, maximum, read), id => id);
143
144
 
144
145
  const placementOf = (value: unknown): TServiceRuntimePlacement => {
145
146
  const kind = variantOf(value, 'kind');
@@ -148,7 +149,7 @@ const placementOf = (value: unknown): TServiceRuntimePlacement => {
148
149
  return { kind };
149
150
  }
150
151
  requireNetwork(kind === 'nodes');
151
- const nodeIds = sortedIds(networkRecord(value, ['kind', 'nodeIds']).nodeIds, serviceRuntimeContract.maximumPinnedNodes);
152
+ const nodeIds = sortedIds(networkRecord(value, ['kind', 'nodeIds']).nodeIds, serviceRuntimeContract.maximumPinnedNodes, networkNodeId);
152
153
  requireNetwork(nodeIds.length > 0);
153
154
  return { kind, nodeIds };
154
155
  };
@@ -182,7 +183,7 @@ const networkOf = (value: unknown): TServiceRuntimeNetwork => {
182
183
  const item = networkRecord(value, ['mode', 'publicEgress', 'platformEndpointIds']);
183
184
  requireNetwork(typeof item.publicEgress === 'boolean');
184
185
  return { mode, publicEgress: item.publicEgress,
185
- platformEndpointIds: sortedIds(item.platformEndpointIds, serviceRuntimeContract.maximumPlatformEndpoints) };
186
+ platformEndpointIds: sortedIds(item.platformEndpointIds, serviceRuntimeContract.maximumPlatformEndpoints, networkId) };
186
187
  };
187
188
 
188
189
  /**
@@ -1,5 +1,6 @@
1
1
  import { isReleaseVersion } from '../private/releaseversion.js';
2
2
  import { readExactDataRecord } from '../private/dataproperties.js';
3
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
3
4
  import { protocolRefusalHttpStatus, readProtocolOffer, type IProtocolOffer } from '../protocol.js';
4
5
 
5
6
  /** Host observations only. Container and workload readiness belong to Pallet. */
@@ -80,6 +81,10 @@ const text = (valueArg: unknown, patternArg: RegExp, maxLengthArg: number): stri
80
81
  && valueArg.match(patternArg)?.[0] === valueArg);
81
82
  return valueArg;
82
83
  };
84
+ // The shared vocabulary, not a copy of it: the id on this wire is the id the node enrolled under.
85
+ const nodeId = (valueArg: unknown): string => (
86
+ isClusterNodeId(valueArg) ? valueArg : fail()
87
+ );
83
88
  // The shared grammar, not a copy of it: a stored bundle version has to be one the release
84
89
  // comparison can order, and a second rule here would be a second answer to that question.
85
90
  const releaseVersion = (valueArg: unknown): string => (
@@ -131,7 +136,7 @@ export const snapshotSparkNodeReport = (valueArg: unknown): ISparkNodeReport =>
131
136
  export const snapshotSparkNodeHeartbeatRequest = (valueArg: unknown): ISparkNodeHeartbeatRequest => {
132
137
  const value = record(valueArg, ['nodeId', 'nodeToken', 'protocol', 'report']);
133
138
  return Object.freeze({
134
- nodeId: text(value.nodeId, /^[A-Za-z0-9_-]+$/, 128),
139
+ nodeId: nodeId(value.nodeId),
135
140
  // Both freshly enrolled and explicitly adopted credentials use this bearer format.
136
141
  nodeToken: text(value.nodeToken, /^secureToken[a-f0-9]{64}$/, 75),
137
142
  protocol: readProtocolOffer(value),
@@ -144,7 +149,7 @@ export const snapshotSparkNodeHeartbeatReceipt = (valueArg: unknown): ISparkNode
144
149
  'sessionEpoch', 'acceptedAt', 'protocol']);
145
150
  requireValue(value.accepted === true);
146
151
  return Object.freeze({
147
- accepted: true, nodeId: text(value.nodeId, /^[A-Za-z0-9_-]+$/, 128),
152
+ accepted: true, nodeId: nodeId(value.nodeId),
148
153
  credentialGeneration: integer(value.credentialGeneration, 1),
149
154
  sessionEpoch: integer(value.sessionEpoch, 0), acceptedAt: integer(value.acceptedAt, 1),
150
155
  protocol: readProtocolOffer(value),
@@ -3,6 +3,7 @@ import type { IRuntimeAuthorityReference, IRuntimeController } from '../data/run
3
3
  import type { IRuntimeNetworkDnsLeaseRenewal } from '../data/networklease.js';
4
4
  import type { IRuntimeNetworkDnsWindow, IRuntimeNetworkNodeScope, IRuntimeNetworkProjection } from '../data/networkprojection.js';
5
5
  import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from './canonicaljson.js';
6
+ import { isClusterNodeId } from './nodeidentifier.js';
6
7
 
7
8
  export const failNetwork = (): never => { throw new Error('Runtime network projection contract rejected.'); };
8
9
  export function requireNetwork(value: unknown): asserts value { if (!value) failNetwork(); }
@@ -79,6 +80,10 @@ export const networkSorted = <T>(values: T[], key: (item: T) => string): T[] =>
79
80
  export const networkId = (value: unknown): string => {
80
81
  requireNetwork(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(value)); return value;
81
82
  };
83
+ /** A node id is read in the node vocabulary here as everywhere else, never in the canonical one. */
84
+ export const networkNodeId = (value: unknown): string => {
85
+ requireNetwork(isClusterNodeId(value)); return value as string;
86
+ };
82
87
  export const networkCounter = (value: unknown, maximum = Number.MAX_SAFE_INTEGER - 1): number => {
83
88
  requireNetwork(typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= maximum); return value;
84
89
  };
@@ -89,13 +94,24 @@ export const networkDigest = (value: unknown): TSha256Digest => {
89
94
  export const networkSignature = (value: unknown): string => {
90
95
  requireNetwork(typeof value === 'string' && /^[a-f0-9]{128}$/.test(value)); return value;
91
96
  };
97
+ /**
98
+ * One encoding for every Noise key this contract carries — the managed VPN credential's, a relay's
99
+ * hub advertisement and a cluster's VPN network: canonical 32-byte base64, never the all-zero key.
100
+ * Key agreement and key-pair validation belong to the native Noise implementation, not to a reader.
101
+ */
102
+ export const networkNoiseKey = (value: unknown): string => {
103
+ requireNetwork(typeof value === 'string' && /^[A-Za-z0-9+/]{43}=$/.test(value));
104
+ const decoded = atob(value as string);
105
+ requireNetwork(decoded.length === 32 && btoa(decoded) === value && [...decoded].some(byte => byte.charCodeAt(0) !== 0));
106
+ return value as string;
107
+ };
92
108
  export const networkController = (value: unknown): IRuntimeController => {
93
109
  const item = networkRecord(value, ['kind', 'id', 'epoch']);
94
110
  requireNetwork(item.kind === 'cloudly' || item.kind === 'onebox');
95
111
  return { kind: item.kind, id: networkId(item.id), epoch: networkId(item.epoch) };
96
112
  };
97
113
  export const networkScope = (item: Record<string, unknown>): IRuntimeNetworkNodeScope => ({
98
- controller: networkController(item.controller), nodeId: networkId(item.nodeId), runtimeNamespace: networkId(item.runtimeNamespace),
114
+ controller: networkController(item.controller), nodeId: networkNodeId(item.nodeId), runtimeNamespace: networkId(item.runtimeNamespace),
99
115
  });
100
116
  export const networkSameScope = (left: IRuntimeNetworkNodeScope, right: IRuntimeNetworkNodeScope): boolean =>
101
117
  networkEqual(left.controller, right.controller) && left.nodeId === right.nodeId && left.runtimeNamespace === right.runtimeNamespace;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The one vocabulary a node of a cluster is named in.
3
+ *
4
+ * It lives beside the canonical JSON and data-property helpers rather than in a contract module
5
+ * because every family reads it — the node's enrollment, its runtime session, the Spark wire, its
6
+ * assignments, the private network contract and the relay block — and a second spelling of the rule
7
+ * is a second answer to which nodes exist.
8
+ */
9
+
10
+ /**
11
+ * True when a value is exactly a node id: the URL alphabet, a leading `-` or `_` included,
12
+ * one to 128 characters.
13
+ *
14
+ * These contracts disagreed with themselves about what a node may be called: enrollment, the
15
+ * session registration and the Spark wire admit a separator-led node id, which makes this the
16
+ * vocabulary a stored node id is written in, while the assignment and the network members read
17
+ * that same id by the canonical identifier vocabulary — whose first character must be
18
+ * alphanumeric — and refuse it, and with it the assignment, the runtime network and, because one
19
+ * refused egress owner fails the whole pass, its entire cluster's protected authority. A
20
+ * controller now keeps the first character alphanumeric where it mints, so the ids this one
21
+ * vocabulary serves are the ones already stored under the members that admit them.
22
+ */
23
+ export const isClusterNodeId = (valueArg: unknown): valueArg is string => (
24
+ typeof valueArg === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(valueArg)
25
+ );
@@ -1,5 +1,6 @@
1
1
  import * as userInterfaces from '../data/user.js';
2
2
  import * as clusterInterfaces from '../data/cluster.js';
3
+ import type * as clusterVpnInterfaces from '../data/clustervpn.js';
3
4
  import type { IClusterRuntime, TClusterRuntimeBlocker } from '../data/clusterruntime.js';
4
5
  import type { IServiceRuntimeAssignmentView } from '../data/serviceruntime.js';
5
6
  import type * as leaseInterfaces from '../data/networklease.js';
@@ -168,6 +169,40 @@ export interface IReq_Cloudly_Relay_HoldRuntimeNetworkDnsLeaseRenewals extends p
168
169
  response: leaseInterfaces.IHoldRuntimeNetworkDnsLeaseRenewalsResponse;
169
170
  }
170
171
 
172
+ /**
173
+ * Cloudly pushes one cluster's complete managed VPN to that cluster's relay.
174
+ *
175
+ * The relay is its cluster's own hub: it runs the managed VPN server every node of the cluster
176
+ * dials, so this is the whole authority that hub serves, never a patch. It rides the relay's
177
+ * already registered session, which is why no cluster is named — the socket names it. The relay
178
+ * answers with what its hub applied, and refuses a network for another authority or a revision it
179
+ * has passed with one of `data.applyClusterVpnNetworkRefusals`.
180
+ */
181
+ export interface IReq_Cloudly_Relay_ApplyClusterVpnNetwork extends plugins.typedrequestInterfaces.implementsTR<
182
+ plugins.typedrequestInterfaces.ITypedRequest,
183
+ IReq_Cloudly_Relay_ApplyClusterVpnNetwork
184
+ > {
185
+ method: 'applyClusterVpnNetwork';
186
+ request: clusterVpnInterfaces.IApplyClusterVpnNetworkRequest;
187
+ response: clusterVpnInterfaces.IApplyClusterVpnNetworkResponse;
188
+ }
189
+
190
+ /**
191
+ * The relay asks Cloudly for its cluster's current managed VPN.
192
+ *
193
+ * A hub daemon starts empty and its lifetime is the relay's own, invisible to Cloudly, so a relay
194
+ * that just bound its hub pulls the current network instead of waiting for the next push. The
195
+ * answer is `null` while the cluster has none.
196
+ */
197
+ export interface IReq_Relay_Cloudly_GetClusterVpnNetwork extends plugins.typedrequestInterfaces.implementsTR<
198
+ plugins.typedrequestInterfaces.ITypedRequest,
199
+ IReq_Relay_Cloudly_GetClusterVpnNetwork
200
+ > {
201
+ method: 'getClusterVpnNetwork';
202
+ request: clusterVpnInterfaces.TGetClusterVpnNetworkRequest;
203
+ response: clusterVpnInterfaces.IGetClusterVpnNetworkResponse;
204
+ }
205
+
171
206
  /**
172
207
  * deletes a cluster
173
208
  */
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import type { IIdentity } from '../data/user.js';
3
3
  import { hasExactKeys } from '../private/dataproperties.js';
4
+ import { isClusterNodeId } from '../private/nodeidentifier.js';
4
5
 
5
6
  /**
6
7
  * A provisioned corestore resource (NoSQLDB database or ObjectStorage bucket)
@@ -189,9 +190,8 @@ export const validateClusterCorestoreInventory = (
189
190
  return [`${pathArg} must use its exact schema`];
190
191
  }
191
192
  const errors: string[] = [];
192
- if (typeof valueArg.nodeId !== 'string'
193
- || !clusterCorestoreInventoryIdentifierPattern.test(valueArg.nodeId)) {
194
- errors.push(`${pathArg} nodeId must be a bounded identifier`);
193
+ if (!isClusterNodeId(valueArg.nodeId)) {
194
+ errors.push(`${pathArg} nodeId must be a node id`);
195
195
  }
196
196
  if (!Number.isSafeInteger(valueArg.checkedAt) || (valueArg.checkedAt as number) < 0) {
197
197
  errors.push(`${pathArg} checkedAt must be a non-negative safe integer`);