@serve.zone/interfaces 32.5.0 → 32.7.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.
@@ -1,6 +1,7 @@
1
- import { captureNetwork, failNetwork, networkCanonical, networkCounter, networkEqual, networkId, networkIpv4, networkList,
2
- networkNodeId, networkNoiseKey, networkPrivatePrefix, networkRecord, networkSafe, networkSorted,
3
- requireNetwork } from '../private/networkcontract.js';
1
+ import { runtimeNetworkVpnControlPrefix } from './networkaddressplan.js';
2
+ import { captureNetwork, failNetwork, networkAuthorityId, networkCanonical, networkContains, networkCounter, networkEqual,
3
+ networkIpv4, networkList, networkMaximumAuthorityIdBytes, networkNodeId, networkNoiseKey, networkOverlaps,
4
+ networkPrivatePrefix, networkRecord, networkSafe, networkSorted, requireNetwork } from '../private/networkcontract.js';
4
5
 
5
6
  /**
6
7
  * One workload prefix a node owns inside its cluster's managed VPN, and the policy domain that
@@ -39,10 +40,18 @@ export interface IClusterVpnNetworkGrant {
39
40
  * Complete, never a patch: a relay applies it as the whole authority its hub serves, so a member or
40
41
  * a grant that is missing is withdrawn. `revision` is Cloudly's own counter for this authority and
41
42
  * only ever rises; `authorityId` names the authority the hub is bound to, so a network compiled for
42
- * another cluster is refused rather than applied.
43
+ * another cluster is refused rather than applied. It is bounded by
44
+ * `clusterVpnNetworkContract.maximumAuthorityIdBytes`, the same bound the hub daemon holds.
45
+ *
46
+ * `controlPrefix` is the subnet the hub's control plane serves: the address plan's
47
+ * `vpn.controlPrefix`, which the plan contract never lets change, stated here because a hub fixes
48
+ * its control subnet when it binds and no other carrier hands a relay the plan. Without it a relay
49
+ * could only infer a subnet from the members it was sent, which is how a hub comes to disagree with
50
+ * the plan it serves.
43
51
  */
44
52
  export interface IClusterVpnNetwork {
45
53
  authorityId: string;
54
+ controlPrefix: string;
46
55
  revision: number;
47
56
  nodes: IClusterVpnNetworkNode[];
48
57
  grants: IClusterVpnNetworkGrant[];
@@ -57,6 +66,11 @@ export const clusterVpnNetworkContract = Object.freeze({
57
66
  /** Per node; one workload lease the node's signed projection carries. */
58
67
  maximumWorkloadPrefixes: 128,
59
68
  maximumGrants: 4096,
69
+ /**
70
+ * UTF-8 bytes, matching what the hub daemon admits for the authority it binds: an id above this
71
+ * is refused where the network is compiled rather than by the daemon at apply time.
72
+ */
73
+ maximumAuthorityIdBytes: networkMaximumAuthorityIdBytes,
60
74
  maximumCanonicalBytes: 917_504,
61
75
  completeness: 'whole-authority-never-a-patch',
62
76
  membership: 'presence-only-an-absent-node-is-withdrawn',
@@ -168,20 +182,27 @@ const grantKey = (grant: IClusterVpnNetworkGrant): string => `${grant.sourceDoma
168
182
  * workload domain this network itself carries, on two different nodes. That last rule is what a
169
183
  * per-cluster hub means: a grant whose other end sits on another cluster's hub cannot be expressed
170
184
  * here at all, which is why Cloudly refuses it by name while it compiles instead of pushing it.
185
+ *
186
+ * Every control address lies inside `controlPrefix` and no workload prefix meets it — the two rules
187
+ * a hub enforces against the subnet it bound. Checking them while the network is compiled turns a
188
+ * push the hub could only refuse into a network Cloudly never states.
171
189
  */
172
190
  export const snapshotClusterVpnNetwork = (value: unknown): IClusterVpnNetwork => networkSafe(() => {
173
191
  const captured = captureNetwork(value);
174
192
  requireNetwork(new TextEncoder().encode(networkCanonical(captured)).length <= clusterVpnNetworkContract.maximumCanonicalBytes);
175
- const item = networkRecord(captured, ['authorityId', 'revision', 'nodes', 'grants']);
193
+ const item = networkRecord(captured, ['authorityId', 'controlPrefix', 'revision', 'nodes', 'grants']);
194
+ const controlPrefix = runtimeNetworkVpnControlPrefix(item.controlPrefix);
176
195
  const nodes = networkSorted(networkList(item.nodes, clusterVpnNetworkContract.maximumNodes, nodeOf), node => node.nodeId);
177
196
  const grants = networkSorted(networkList(item.grants, clusterVpnNetworkContract.maximumGrants, grantOf), grantKey);
178
197
  const domainOwners = new Map<string, string>();
179
198
  const addresses = new Set<string>(), keys = new Set<string>(), cidrs = new Set<string>();
180
199
  for (const node of nodes) {
181
- requireNetwork(!keys.has(node.publicKey) && !addresses.has(node.controlAddress));
200
+ requireNetwork(!keys.has(node.publicKey) && !addresses.has(node.controlAddress)
201
+ && networkContains(controlPrefix, `${node.controlAddress}/32`));
182
202
  keys.add(node.publicKey); addresses.add(node.controlAddress);
183
203
  for (const prefix of node.workloadPrefixes) {
184
- requireNetwork(!cidrs.has(prefix.cidr) && !domainOwners.has(prefix.policyDomain));
204
+ requireNetwork(!cidrs.has(prefix.cidr) && !domainOwners.has(prefix.policyDomain)
205
+ && !networkOverlaps(controlPrefix, prefix.cidr));
185
206
  cidrs.add(prefix.cidr); domainOwners.set(prefix.policyDomain, node.nodeId);
186
207
  }
187
208
  }
@@ -189,7 +210,7 @@ export const snapshotClusterVpnNetwork = (value: unknown): IClusterVpnNetwork =>
189
210
  const source = domainOwners.get(grant.sourceDomain), destination = domainOwners.get(grant.destinationDomain);
190
211
  requireNetwork(source && destination && source !== destination);
191
212
  }
192
- return { authorityId: networkId(item.authorityId), revision: networkCounter(item.revision), nodes, grants };
213
+ return { authorityId: networkAuthorityId(item.authorityId), controlPrefix, revision: networkCounter(item.revision), nodes, grants };
193
214
  });
194
215
 
195
216
  export type TClusterVpnNetworkAdmission =
@@ -208,7 +229,11 @@ export const admitClusterVpnNetwork = (heldValue: unknown | null, nextValue: unk
208
229
  const next = snapshotClusterVpnNetwork(nextValue);
209
230
  if (heldValue === null) return { status: 'accepted' };
210
231
  const held = snapshotClusterVpnNetwork(heldValue);
211
- if (held.authorityId !== next.authorityId) return { status: 'refused', reason: 'vpn-network-authority-foreign' };
232
+ // A hub binds one control subnet for its lifetime, so a network that moves the prefix under the
233
+ // same authority is as foreign to this hub as one that names another authority.
234
+ if (held.authorityId !== next.authorityId || held.controlPrefix !== next.controlPrefix) {
235
+ return { status: 'refused', reason: 'vpn-network-authority-foreign' };
236
+ }
212
237
  if (next.revision > held.revision) return { status: 'accepted' };
213
238
  return networkEqual(held, next) ? { status: 'replay' } : { status: 'refused', reason: 'vpn-network-revision-stale' };
214
239
  });
@@ -5,6 +5,7 @@ import type {
5
5
  } from './immutableimage.js';
6
6
  import { normalizeImmutableReleaseTag, normalizeSha256Digest } from './immutableimage.js';
7
7
  import type { TServiceDeploymentCapability } from './user.js';
8
+ import { validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
8
9
  import type {
9
10
  IDeploymentRouteRequest,
10
11
  IServiceDeploymentOperation,
@@ -190,6 +191,26 @@ export interface IDeploymentPreflightRequestData {
190
191
  routes?: IDeploymentRouteRequest[];
191
192
  /** Legacy v14 discriminator. Equivalent to mode: 'greenfield'. */
192
193
  greenfield?: true;
194
+ /**
195
+ * The deploying build's protocol offer, read and negotiated before this request is validated.
196
+ *
197
+ * This is the one request that opens a deployment, which is why the offer rides here and on no
198
+ * mutating deployment carrier: Cloudly derives `reserveServiceDeployment`'s `requestDigest` from
199
+ * the whole request body, so an offer inside one of those would bind an operation's identity to
200
+ * the interfaces release the deployer happened to have installed, and an in-flight deployment
201
+ * would stop being resumable across a deployer upgrade. Naming the skew on the read that opens
202
+ * the flow costs nothing durable and refuses it before anything is reserved.
203
+ *
204
+ * Optional, because a peer that speaks this release states nothing by offering and older
205
+ * deployers send none: absence is a valid request, not a refusal. A member stated as `undefined`
206
+ * is absent too, like every other optional member of this shape, so a caller that spreads an
207
+ * offer it may not hold states no offer rather than a malformed one. When an offer is stated it
208
+ * is exact-key, exactly like every other offer in this package —
209
+ * `validateDeploymentPreflightRequest` judges it with the same reader
210
+ * `protocol.readProtocolOffer` negotiates from, so a body cannot pass this validator with an
211
+ * offer the server's own read would refuse.
212
+ */
213
+ protocol?: IProtocolOffer;
193
214
  }
194
215
 
195
216
  export interface IDeploymentPreflightReport {
@@ -412,6 +433,23 @@ export const validateDeploymentPreflightRequest = (
412
433
  )) {
413
434
  blockers.push({ code: 'INVALID_REQUEST', message: 'Deployment route declaration is malformed.' });
414
435
  }
436
+ // Presence is decided from the own property alone: reading `requestArg.protocol` would run an
437
+ // accessor a caller attached to the body, and the offer a validator judges has to be the value
438
+ // the server negotiates from. A data property holding `undefined` states no offer, exactly as
439
+ // the optional members above read an omitted value, so spreading an offer a caller may not hold
440
+ // is not a malformed one. An accessor is neither of those: it is refused, and never invoked —
441
+ // the check reads a descriptor, and `validateProtocolOfferOn` reads the key as data as well.
442
+ const protocolProperty = Object.getOwnPropertyDescriptor(requestArg, 'protocol');
443
+ const statedProtocolOffer = protocolProperty !== undefined
444
+ && !(Object.hasOwn(protocolProperty, 'value') && protocolProperty.value === undefined)
445
+ ? validateProtocolOfferOn(requestArg, 'deployment preflight protocol offer')
446
+ : [];
447
+ if (statedProtocolOffer.length > 0) {
448
+ blockers.push({
449
+ code: 'INVALID_REQUEST',
450
+ message: `The stated protocol offer is malformed: ${statedProtocolOffer.join('; ')}.`,
451
+ });
452
+ }
415
453
  if (mode === 'greenfield' && Array.isArray(requestArg.routes)
416
454
  && JSON.stringify([...new Set(requestArg.routes.map((routeArg) => (
417
455
  isRecord(routeArg) && typeof routeArg.hostname === 'string' ? routeArg.hostname : ''
@@ -39,14 +39,13 @@ export interface IRuntimeNetworkAddressPlan {
39
39
  * One cluster's managed VPN hub, as the cluster's relay bound it.
40
40
  *
41
41
  * The hub is its cluster's own packet relay, so `clusterId` names both the hub and the authority
42
- * that may select it, and `address` is an underlay address inside the plan's `vpn.hubPrefix`. A hub
43
- * offers at least one transport; the port of a transport it did not bind is `null`.
42
+ * that may select it, and `address` is an underlay address inside the plan's `vpn.hubPrefix`.
43
+ * `quicPort` is the port it bound for the one transport a managed hub serves.
44
44
  */
45
45
  export interface IRuntimeNetworkVpnHub {
46
46
  clusterId: string;
47
47
  address: string;
48
- quicPort: number | null;
49
- websocketPort: number | null;
48
+ quicPort: number;
50
49
  }
51
50
 
52
51
  /** What an administrator writes; Cloudly supplies the id and the revision. */
@@ -80,50 +79,60 @@ export const runtimeNetworkAddressPlanContract = Object.freeze({
80
79
  */
81
80
  maximumControlPrefixLength: 30,
82
81
  /**
83
- * Every transport a hub may offer, in the order a node prefers them.
82
+ * Every transport a managed hub serves, in the order a node prefers them.
84
83
  *
85
84
  * One row states all three facts that belong together: the transport a credential names, the
86
85
  * suffix its platform endpoint id carries, and the protocol the endpoint is composed with. A hub
87
- * that bound both transports therefore contributes exactly two endpoints of one address.
86
+ * contributes one endpoint of its address per row, so a transport added here is added once for
87
+ * the id derivation, the composition and the credential binder alike.
88
+ *
89
+ * QUIC is the one row: a managed hub terminates TLS on its QUIC listener alone, so no other
90
+ * transport of it can be dialled over a protected connection.
88
91
  */
89
92
  hubTransports: Object.freeze([
90
93
  Object.freeze({ transport: 'quic', endpointSuffix: 'quic', protocol: 'udp' }),
91
- Object.freeze({ transport: 'websocket', endpointSuffix: 'ws', protocol: 'tcp' }),
92
94
  ]),
93
95
  } as const);
94
96
 
97
+ /**
98
+ * The one reader for a managed VPN control prefix: a canonical private prefix no longer than
99
+ * `maximumControlPrefixLength`. The plan states it for the whole runtime network and a cluster's
100
+ * network repeats it for the hub that binds it, so both read it here and neither can admit a
101
+ * subnet the other would refuse.
102
+ */
103
+ export const runtimeNetworkVpnControlPrefix = (value: unknown): string => networkSafe(() => {
104
+ const prefix = networkPrivatePrefix(value);
105
+ requireNetwork(Number(prefix.split('/')[1]) <= runtimeNetworkAddressPlanContract.maximumControlPrefixLength);
106
+ return prefix;
107
+ });
108
+
95
109
  /** How a node reaches its cluster's hub, read off the one table that states the transports. */
96
110
  export type TRuntimeNetworkVpnTransport = typeof runtimeNetworkAddressPlanContract.hubTransports[number]['transport'];
97
111
 
98
- /** The port a hub bound for one transport, or null when it did not bind that transport. */
99
- const hubPort = (hub: IRuntimeNetworkVpnHub, transport: TRuntimeNetworkVpnTransport): number | null =>
100
- transport === 'quic' ? hub.quicPort : hub.websocketPort;
112
+ /** The port a hub bound for one transport; one entry per row of the transport table. */
113
+ const hubPort = (hub: IRuntimeNetworkVpnHub, transport: TRuntimeNetworkVpnTransport): number =>
114
+ ({ quic: hub.quicPort })[transport];
101
115
 
102
116
  const byEndpointId = <T extends { id: string }>(left: T, right: T): number =>
103
117
  left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
104
118
 
105
119
  const hubOf = (value: unknown): IRuntimeNetworkVpnHub => {
106
- const item = networkRecord(value, ['clusterId', 'address', 'quicPort', 'websocketPort']);
107
- const port = (value: unknown): number | null => value === null ? null : networkCounter(value, 65535);
108
- const hub: IRuntimeNetworkVpnHub = { clusterId: networkId(item.clusterId), address: networkIpv4(item.address),
109
- quicPort: port(item.quicPort), websocketPort: port(item.websocketPort) };
110
- // A hub that bound no transport is not reachable, so it is not a hub a node could be sent to.
111
- requireNetwork(runtimeNetworkAddressPlanContract.hubTransports.some(row => hubPort(hub, row.transport) !== null));
112
- return hub;
120
+ const item = networkRecord(value, ['clusterId', 'address', 'quicPort']);
121
+ return { clusterId: networkId(item.clusterId), address: networkIpv4(item.address),
122
+ quicPort: networkCounter(item.quicPort, 65535) };
113
123
  };
114
124
 
115
125
  /**
116
- * The platform endpoints one hub contributes to a protected authority: one per transport it bound,
117
- * in id order. Every reader derives the same ids from this one function — the composition that seals
118
- * the authority, and the router selection that names which of them a node may dial.
126
+ * The platform endpoints one hub contributes to a protected authority: one per transport a managed
127
+ * hub serves, in id order. Every reader derives the same ids from this one function — the composition
128
+ * that seals the authority, and the router selection that names which of them a node may dial.
119
129
  */
120
130
  export const runtimeNetworkVpnHubEndpoints = (hubValue: unknown): IRuntimeNetworkProtectedAuthority['platformEndpoints'] =>
121
131
  networkSafe(() => {
122
132
  const hub = hubOf(captureNetwork(hubValue));
123
133
  return runtimeNetworkAddressPlanContract.hubTransports
124
- .filter(row => hubPort(hub, row.transport) !== null)
125
134
  .map(row => ({ id: `${hub.clusterId}:${row.endpointSuffix}`, address: hub.address,
126
- protocol: row.protocol, port: hubPort(hub, row.transport) as number }))
135
+ protocol: row.protocol, port: hubPort(hub, row.transport) }))
127
136
  .sort(byEndpointId);
128
137
  });
129
138
 
@@ -154,9 +163,7 @@ const setsWithHubs = (plan: IRuntimeNetworkAddressPlan, hubs: IRuntimeNetworkVpn
154
163
 
155
164
  const vpnOf = (value: unknown): IRuntimeNetworkAddressPlan['vpn'] => {
156
165
  const item = networkRecord(value, ['controlPrefix', 'hubPrefix']);
157
- const controlPrefix = networkPrivatePrefix(item.controlPrefix);
158
- requireNetwork(Number(controlPrefix.split('/')[1]) <= runtimeNetworkAddressPlanContract.maximumControlPrefixLength);
159
- return { controlPrefix, hubPrefix: networkPrefix(item.hubPrefix) };
166
+ return { controlPrefix: runtimeNetworkVpnControlPrefix(item.controlPrefix), hubPrefix: networkPrefix(item.hubPrefix) };
160
167
  };
161
168
 
162
169
  /**
@@ -3,9 +3,9 @@ import { runtimeNetworkAddressPlanContract, type TRuntimeNetworkVpnTransport } f
3
3
  import { snapshotRuntimeSessionBinding, type IRuntimeSessionBinding } from './runtimesession.js';
4
4
  import { validateRuntimeNetworkProjection, type IRuntimeNetworkProjection } from './networkprojection.js';
5
5
  import { snapshotRuntimeNetworkDnsLeaseRenewal, validateRuntimeNetworkDnsLeaseRenewal } from './networklease.js';
6
- import { captureNetwork, failNetwork, networkCounter, networkEffectiveDnsWindow, networkEqual, networkId, networkIpv4,
7
- networkNoiseKey, networkRecord, networkReference, networkReferenceOf, networkSafe, networkSameScope,
8
- requireNetwork } from '../private/networkcontract.js';
6
+ import { captureNetwork, failNetwork, networkAuthorityId, networkCounter, networkEffectiveDnsWindow, networkEqual,
7
+ networkId, networkIpv4, networkNoiseKey, networkRecord, networkReference, networkReferenceOf, networkSafe,
8
+ networkSameScope, requireNetwork } from '../private/networkcontract.js';
9
9
 
10
10
  export interface IGetRuntimeManagedVpnCredentialRequest {
11
11
  session: IRuntimeSessionBinding;
@@ -19,13 +19,7 @@ export interface IGetRuntimeManagedVpnCredentialResponse extends IGetRuntimeMana
19
19
  transport: TRuntimeNetworkVpnTransport;
20
20
  /** The cluster hub endpoint this node dials, named by the id its signed projection selected. */
21
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;
22
+ /** The hub's managed authority, under the one bound the hub daemon holds for it. */
29
23
  authorityId: string;
30
24
  serverPublicKey: string;
31
25
  clientPublicKey: string;
@@ -42,21 +36,6 @@ export const snapshotGetRuntimeManagedVpnCredentialRequest = (value: unknown): I
42
36
  return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection) };
43
37
  });
44
38
 
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
39
  const transportOf = (value: unknown): TRuntimeNetworkVpnTransport => {
61
40
  const row = runtimeNetworkAddressPlanContract.hubTransports.find(candidate => candidate.transport === value);
62
41
  requireNetwork(row); return row.transport;
@@ -64,15 +43,12 @@ const transportOf = (value: unknown): TRuntimeNetworkVpnTransport => {
64
43
 
65
44
  export const snapshotGetRuntimeManagedVpnCredentialResponse = (value: unknown): IGetRuntimeManagedVpnCredentialResponse => networkSafe(() => {
66
45
  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,
46
+ 'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt']);
47
+ const hub = networkRecord(item.hub, ['id', 'address', 'port']);
48
+ return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection),
49
+ transport: transportOf(item.transport),
73
50
  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),
51
+ authorityId: networkAuthorityId(item.authorityId), serverPublicKey: networkNoiseKey(item.serverPublicKey),
76
52
  clientPublicKey: networkNoiseKey(item.clientPublicKey), clientPrivateKey: networkNoiseKey(item.clientPrivateKey),
77
53
  expiresAt: networkCounter(item.expiresAt) };
78
54
  });
@@ -80,6 +80,26 @@ export const networkSorted = <T>(values: T[], key: (item: T) => string): T[] =>
80
80
  export const networkId = (value: unknown): string => {
81
81
  requireNetwork(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(value)); return value;
82
82
  };
83
+ /**
84
+ * The longest managed VPN authority id any contract of this package admits, in UTF-8 bytes.
85
+ *
86
+ * SmartVPN's managed registry refuses an authority id longer than 128 bytes, and it is the daemon
87
+ * that applies a pushed network, so an id between that bound and the generic identifier bound
88
+ * above would pass every contract on the way and be refused only at apply time, on the far side of
89
+ * a push, as a hub reporting `state: 'failed'`. Bounding it where the id is admitted moves that
90
+ * refusal to where Cloudly compiles the network, on its own stack, before anything is pushed.
91
+ *
92
+ * Bytes, because that is what the daemon counts. The identifier grammar admits ASCII alone, so a
93
+ * byte and a code point are the same thing for every id that gets this far; measuring bytes keeps
94
+ * the bound equal to the daemon's the day the grammar admits anything wider.
95
+ */
96
+ export const networkMaximumAuthorityIdBytes = 128;
97
+ /** A managed VPN authority id: a canonical identifier the cluster's hub daemon also admits. */
98
+ export const networkAuthorityId = (value: unknown): string => {
99
+ const id = networkId(value);
100
+ requireNetwork(new TextEncoder().encode(id).length <= networkMaximumAuthorityIdBytes);
101
+ return id;
102
+ };
83
103
  /** A node id is read in the node vocabulary here as everywhere else, never in the canonical one. */
84
104
  export const networkNodeId = (value: unknown): string => {
85
105
  requireNetwork(isClusterNodeId(value)); return value as string;
@@ -16,6 +16,7 @@ import type {
16
16
  IServiceDeploymentStatus,
17
17
  } from '../data/deploymentoperation.js';
18
18
  import type { TSha256Digest } from '../data/immutableimage.js';
19
+ import type { IProtocolOffer } from '../protocol.js';
19
20
  import type {
20
21
  IServiceRuntimeAssignmentView,
21
22
  IServiceRuntimeSpec,
@@ -113,6 +114,18 @@ extends plugins.typedrequestInterfaces.implementsTR<
113
114
  request: {
114
115
  identity: IIdentity;
115
116
  serviceId: string;
117
+ /**
118
+ * The calling build's protocol offer, read and negotiated before the request is served.
119
+ *
120
+ * The read that opens a deployment adoption, and the only other deployment carrier that
121
+ * carries an offer: the mutating ones stay byte-exact because Cloudly derives a deployment
122
+ * operation's `requestDigest` from the whole request body, and an offer there would bind that
123
+ * operation's identity to the caller's interfaces release. Optional, because a peer of this
124
+ * release states nothing by offering and a caller that opens no deployment sends none —
125
+ * absence is a valid request. A stated offer is exact-key, judged by
126
+ * `protocol.readProtocolOffer` / `protocol.validateProtocolOfferOn` like every other offer.
127
+ */
128
+ protocol?: IProtocolOffer;
116
129
  };
117
130
  response: {
118
131
  service: IService;