@serve.zone/interfaces 32.28.0 → 32.30.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 (39) hide show
  1. package/changelog.md +81 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/cluster.d.ts +1 -1
  4. package/dist_ts/data/cluster.js +6 -1
  5. package/dist_ts/data/networkaddressplan.d.ts +27 -3
  6. package/dist_ts/data/networkaddressplan.js +38 -2
  7. package/dist_ts/data/networkprojection.d.ts +74 -3
  8. package/dist_ts/data/networkprojection.js +21 -2
  9. package/dist_ts/data/secret.d.ts +11 -0
  10. package/dist_ts/data/secret.js +13 -1
  11. package/dist_ts/data/serviceruntime.d.ts +19 -3
  12. package/dist_ts/data/serviceruntime.js +2 -2
  13. package/dist_ts/data/settings.d.ts +51 -0
  14. package/dist_ts/data/settings.js +83 -1
  15. package/dist_ts/platform/corestoreendpointselection.d.ts +37 -0
  16. package/dist_ts/platform/corestoreendpointselection.js +45 -0
  17. package/dist_ts/platform/index.d.ts +3 -1
  18. package/dist_ts/platform/index.js +4 -2
  19. package/dist_ts/platform/objectstorage.js +5 -1
  20. package/dist_ts/private/networkcontract.d.ts +20 -0
  21. package/dist_ts/private/networkcontract.js +31 -9
  22. package/dist_ts/requests/network.d.ts +62 -3
  23. package/dist_ts/requests/settings.d.ts +56 -2
  24. package/dist_ts/requests/settings.js +91 -1
  25. package/package.json +3 -3
  26. package/readme.md +151 -12
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/data/cluster.ts +5 -0
  29. package/ts/data/networkaddressplan.ts +66 -4
  30. package/ts/data/networkprojection.ts +96 -4
  31. package/ts/data/secret.ts +16 -0
  32. package/ts/data/serviceruntime.ts +19 -3
  33. package/ts/data/settings.ts +127 -0
  34. package/ts/platform/corestoreendpointselection.ts +79 -0
  35. package/ts/platform/index.ts +3 -0
  36. package/ts/platform/objectstorage.ts +4 -0
  37. package/ts/private/networkcontract.ts +43 -4
  38. package/ts/requests/network.ts +58 -3
  39. package/ts/requests/settings.ts +162 -2
@@ -3,7 +3,7 @@ import type { IIdentity, IIdentityCredential } from '../data/user.js';
3
3
  import type { IRuntimeNetworkAddressPlan, TRuntimeNetworkAddressPlanWritableData } from '../data/networkaddressplan.js';
4
4
  import type { IRuntimeNetworkNodeReadiness, IRuntimeRouterIncarnation } from '../data/networknode.js';
5
5
  import type { IPrivateNetwork, IServicePrivateNetworks, ISetServicePrivateNetworks, TPrivateNetworkMutation } from '../data/privatenetwork.js';
6
- import type { IServicePublishedPorts, ISetServicePublishedPorts } from '../data/networkprojection.js';
6
+ import type { IServiceNetworkEgress, IServicePublishedPorts, ISetServiceNetworkEgress, ISetServicePublishedPorts } from '../data/networkprojection.js';
7
7
  /** Current organization picker data. Entries are never cached authorization grants. */
8
8
  export interface IReq_Cloudly_ListOwnedOrganizations extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_Cloudly_ListOwnedOrganizations> {
9
9
  method: 'listOwnedOrganizations';
@@ -97,7 +97,7 @@ export interface IReq_Cloudly_SetServicePrivateNetworks extends plugins.typedreq
97
97
  replayed: boolean;
98
98
  };
99
99
  }
100
- /** null means the service has never had an authorized published-port policy. */
100
+ /** null means the service has never had a policy document, neither published ports nor egress. */
101
101
  export interface IReq_Cloudly_GetServicePublishedPorts extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_Cloudly_GetServicePublishedPorts> {
102
102
  method: 'getServicePublishedPorts';
103
103
  request: {
@@ -109,6 +109,17 @@ export interface IReq_Cloudly_GetServicePublishedPorts extends plugins.typedrequ
109
109
  publishedPorts: IServicePublishedPorts | null;
110
110
  };
111
111
  }
112
+ /**
113
+ * Writes the published-port half of one service's policy document, carrying its egress forward.
114
+ *
115
+ * One document per service holds its published ports and its egress under one `revision`. The
116
+ * first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
117
+ * with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
118
+ * no published ports. From then on both reads return it, never null. It is an explicit policy: an
119
+ * attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
120
+ * no public address and no platform endpoint; `network-policy-missing` means the service has no
121
+ * document at all.
122
+ */
112
123
  export interface IReq_Cloudly_SetServicePublishedPorts extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_Cloudly_SetServicePublishedPorts> {
113
124
  method: 'setServicePublishedPorts';
114
125
  request: {
@@ -125,6 +136,53 @@ export interface IReq_Cloudly_SetServicePublishedPorts extends plugins.typedrequ
125
136
  replayed: boolean;
126
137
  };
127
138
  }
139
+ /**
140
+ * The egress half of one service's policy document; null means the service has never had a policy
141
+ * document. Only a verified administrator reads it: platform endpoints are platform authority.
142
+ */
143
+ export interface IReq_Admin_Cloudly_GetServiceNetworkEgress extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_Cloudly_GetServiceNetworkEgress> {
144
+ method: 'getServiceNetworkEgress';
145
+ request: {
146
+ identity: IIdentityCredential;
147
+ organizationId: string;
148
+ serviceId: string;
149
+ };
150
+ response: {
151
+ networkEgress: IServiceNetworkEgress | null;
152
+ };
153
+ }
154
+ /**
155
+ * Writes the egress half of one service's policy document, carrying its published ports forward.
156
+ *
157
+ * Only a verified administrator writes it, as only an administrator writes the runtime spec whose
158
+ * attached network must state the same values. `expectedRevision` is the revision of the one
159
+ * document shared with the published ports; any other stored revision refuses the write, and an id
160
+ * that names no current platform endpoint refuses it. A retry of the exact body under the same
161
+ * `mutationId` answers the stored document with `replayed: true`.
162
+ *
163
+ * One document per service holds its published ports and its egress under one `revision`. The
164
+ * first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
165
+ * with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
166
+ * no published ports. From then on both reads return it, never null. It is an explicit policy: an
167
+ * attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
168
+ * no public address and no platform endpoint; `network-policy-missing` means the service has no
169
+ * document at all.
170
+ */
171
+ export interface IReq_Admin_Cloudly_SetServiceNetworkEgress extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_Cloudly_SetServiceNetworkEgress> {
172
+ method: 'setServiceNetworkEgress';
173
+ request: {
174
+ identity: IIdentityCredential;
175
+ mutation: ISetServiceNetworkEgress;
176
+ };
177
+ /**
178
+ * The accepted document including its `revision`; historical on replay. Acceptance is neither an
179
+ * admitted workload nor applied packet state.
180
+ */
181
+ response: {
182
+ networkEgress: IServiceNetworkEgress;
183
+ replayed: boolean;
184
+ };
185
+ }
128
186
  export interface IRequest_Any_Cloudly_GetNetworkNodes {
129
187
  method: 'getNetworkNodes';
130
188
  request: {};
@@ -139,7 +197,8 @@ export interface IRequest_Any_Cloudly_GetNetworkNodes {
139
197
  * validates the plan with `snapshotRuntimeNetworkAddressPlan`, decides the change with
140
198
  * `admitRuntimeNetworkAddressPlanChange`, refuses the change while a resolver or platform endpoint
141
199
  * it removes or changes is still referenced, and answers with the stored plan: at its new revision,
142
- * or unchanged.
200
+ * or unchanged. A list stated out of its canonical order (see `IRuntimeNetworkAddressPlan`) is
201
+ * refused `plan-unsorted`, naming the list as the refusal's reference; the plan is never reordered.
143
202
  */
144
203
  export interface IReq_Any_Cloudly_SetRuntimeNetworkAddressPlan extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_Any_Cloudly_SetRuntimeNetworkAddressPlan> {
145
204
  method: 'setRuntimeNetworkAddressPlan';
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { ICloudlySettings } from '../data/settings.js';
3
- import type { IIdentity } from '../data/user.js';
2
+ import type { IActiveSecretRecipientMetadata } from '../data/secret.js';
3
+ import type { ICloudlySettings, IExternalGatewayEnrollmentState } from '../data/settings.js';
4
+ import type { IIdentity, IIdentityCredential } from '../data/user.js';
4
5
  export interface IRequest_GetSettings extends plugins.typedrequestInterfaces.ITypedRequest {
5
6
  method: 'getSettings';
6
7
  request: {
@@ -54,3 +55,56 @@ export interface IRequest_GetSetting extends plugins.typedrequestInterfaces.ITyp
54
55
  value: ICloudlySettings[keyof ICloudlySettings] | undefined;
55
56
  };
56
57
  }
58
+ /** A dcrouter admin bootstrap token, sealed to Cloudly's active secret ingress recipient. */
59
+ export interface IExternalGatewayBootstrapTokenInput {
60
+ mode: 'sealed';
61
+ /** Sealed under `data.createGatewayBootstrapEnvelopeContext` for the request's expected target. */
62
+ envelope: plugins.smartcrypto.IX25519EnvelopeV1;
63
+ }
64
+ /**
65
+ * Enrolls Cloudly as its dcrouter gateway client with a dcrouter admin token an administrator
66
+ * hands in once.
67
+ *
68
+ * Cloudly opens the token, checks the target the request names against its own settings, asks
69
+ * dcrouter whether the token is an admin token, and provisions, finalizes and stores its own
70
+ * least-privilege gateway-client credential with it — under the policy its settings and the runtime
71
+ * network address plan state now. The admin token is used for this one call and never stored; the
72
+ * administrator revokes it at dcrouter afterwards. A refusal leaves the credential Cloudly already
73
+ * holds unchanged and is answered with one of `data.externalGatewayBootstrapRefusals`.
74
+ *
75
+ * This is the one way a gateway credential enters Cloudly, for a first enrollment and for the
76
+ * re-bootstrap a changed address plan or gateway setting asks for (`admin-bootstrap-required`).
77
+ */
78
+ export interface IReq_Any_Cloudly_BootstrapExternalGatewayCredential extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_Any_Cloudly_BootstrapExternalGatewayCredential> {
79
+ method: 'bootstrapExternalGatewayCredential';
80
+ request: {
81
+ identity: IIdentityCredential;
82
+ /** The `dcrouterGatewayUrl` the administrator read from Cloudly's settings. */
83
+ expectedGatewayUrl: string;
84
+ /** The `dcrouterGatewayClientId` the administrator read from Cloudly's settings. */
85
+ expectedGatewayClientId: string;
86
+ bootstrapToken: IExternalGatewayBootstrapTokenInput;
87
+ };
88
+ response: {
89
+ /** The enrollment after the bootstrap: `ready` when the new credential is Cloudly's runtime authority. */
90
+ state: IExternalGatewayEnrollmentState;
91
+ };
92
+ }
93
+ /** Cloudly's gateway-client enrollment state, for an administrator; it never carries a credential. */
94
+ export interface IReq_Any_Cloudly_GetExternalGatewayEnrollmentState extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_Any_Cloudly_GetExternalGatewayEnrollmentState> {
95
+ method: 'getExternalGatewayEnrollmentState';
96
+ request: {
97
+ identity: IIdentityCredential;
98
+ };
99
+ response: {
100
+ state: IExternalGatewayEnrollmentState;
101
+ };
102
+ }
103
+ /**
104
+ * Checks a bootstrap request against its schema and the active ingress recipient, without opening
105
+ * the envelope: the recipient, the context digest of the named target and the ciphertext bound of
106
+ * `data.externalGatewayBootstrapContract.maximumTokenBytes`. Every problem is named.
107
+ */
108
+ export declare const validateBootstrapExternalGatewayCredentialRequest: (requestArg: unknown, activeIngressRecipientArg: IActiveSecretRecipientMetadata) => Promise<string[]>;
109
+ /** Checks an enrollment state read; every problem is named. */
110
+ export declare const validateGetExternalGatewayEnrollmentStateRequest: (requestArg: unknown) => string[];
@@ -1,2 +1,92 @@
1
1
  import * as plugins from '../plugins.js';
2
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2V0dGluZ3MuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9yZXF1ZXN0cy9zZXR0aW5ncy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGVBQWUsQ0FBQyJ9
2
+ import * as secretData from '../data/secret.js';
3
+ import * as settingsData from '../data/settings.js';
4
+ import { readDataProperties, hasExactKeys } from '../private/dataproperties.js';
5
+ const hasJwtIdentity = (valueArg) => {
6
+ const identity = readDataProperties(valueArg);
7
+ return identity !== null && typeof identity.jwt === 'string' && identity.jwt.length > 0;
8
+ };
9
+ /** Structural check of the target a bootstrap request names; empty when both are well formed. */
10
+ const validateExpectedGatewayTarget = (urlArg, clientIdArg) => {
11
+ const errors = [];
12
+ let url;
13
+ if (typeof urlArg === 'string'
14
+ && urlArg.length > 0
15
+ && urlArg.length <= settingsData.externalGatewayBootstrapContract.maximumGatewayUrlLength) {
16
+ try {
17
+ url = new URL(urlArg);
18
+ }
19
+ catch {
20
+ url = undefined;
21
+ }
22
+ }
23
+ if (!url
24
+ || url.protocol !== 'https:'
25
+ || url.username !== ''
26
+ || url.password !== ''
27
+ || url.search !== ''
28
+ || url.hash !== '') {
29
+ errors.push('bootstrap expectedGatewayUrl must be an HTTPS URL without credentials, query or fragment');
30
+ }
31
+ if (typeof clientIdArg !== 'string'
32
+ || clientIdArg.length === 0
33
+ || clientIdArg.length > settingsData.externalGatewayBootstrapContract.maximumGatewayClientIdLength
34
+ || !/^[\x21-\x7e]+$/.test(clientIdArg)) {
35
+ errors.push('bootstrap expectedGatewayClientId must be a bounded identifier without whitespace');
36
+ }
37
+ return errors;
38
+ };
39
+ /**
40
+ * Checks a bootstrap request against its schema and the active ingress recipient, without opening
41
+ * the envelope: the recipient, the context digest of the named target and the ciphertext bound of
42
+ * `data.externalGatewayBootstrapContract.maximumTokenBytes`. Every problem is named.
43
+ */
44
+ export const validateBootstrapExternalGatewayCredentialRequest = async (requestArg, activeIngressRecipientArg) => {
45
+ const request = readDataProperties(requestArg);
46
+ if (request === null)
47
+ return ['bootstrap request must be a plain record'];
48
+ const errors = [];
49
+ if (!hasExactKeys(request, ['identity', 'expectedGatewayUrl', 'expectedGatewayClientId', 'bootstrapToken'])) {
50
+ errors.push('bootstrap request contains fields outside its schema');
51
+ }
52
+ if (!hasJwtIdentity(request.identity)) {
53
+ errors.push('bootstrap identity must be a JWT credential');
54
+ }
55
+ const targetErrors = validateExpectedGatewayTarget(request.expectedGatewayUrl, request.expectedGatewayClientId);
56
+ errors.push(...targetErrors);
57
+ const tokenInput = readDataProperties(request.bootstrapToken);
58
+ if (tokenInput === null || tokenInput.mode !== 'sealed') {
59
+ errors.push('bootstrap token must be a sealed input');
60
+ return errors;
61
+ }
62
+ const expectedContext = targetErrors.length === 0
63
+ ? secretData.createGatewayBootstrapEnvelopeContext({
64
+ gatewayUrl: request.expectedGatewayUrl,
65
+ gatewayClientId: request.expectedGatewayClientId,
66
+ })
67
+ : new Uint8Array();
68
+ errors.push(...await secretData.validateSecretValueInputForRecipient(tokenInput, activeIngressRecipientArg, expectedContext));
69
+ const envelope = readDataProperties(tokenInput.envelope);
70
+ if (envelope !== null
71
+ && typeof envelope.ciphertext === 'string'
72
+ && envelope.ciphertext.length
73
+ > Math.ceil(settingsData.externalGatewayBootstrapContract.maximumTokenBytes * 4 / 3)) {
74
+ errors.push(`bootstrap token must not exceed ${settingsData.externalGatewayBootstrapContract.maximumTokenBytes} bytes`);
75
+ }
76
+ return errors;
77
+ };
78
+ /** Checks an enrollment state read; every problem is named. */
79
+ export const validateGetExternalGatewayEnrollmentStateRequest = (requestArg) => {
80
+ const request = readDataProperties(requestArg);
81
+ if (request === null)
82
+ return ['enrollment state request must be a plain record'];
83
+ const errors = [];
84
+ if (!hasExactKeys(request, ['identity'])) {
85
+ errors.push('enrollment state request contains fields outside its schema');
86
+ }
87
+ if (!hasJwtIdentity(request.identity)) {
88
+ errors.push('enrollment state identity must be a JWT credential');
89
+ }
90
+ return errors;
91
+ };
92
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2V0dGluZ3MuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi90cy9yZXF1ZXN0cy9zZXR0aW5ncy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGVBQWUsQ0FBQztBQUN6QyxPQUFPLEtBQUssVUFBVSxNQUFNLG1CQUFtQixDQUFDO0FBQ2hELE9BQU8sS0FBSyxZQUFZLE1BQU0scUJBQXFCLENBQUM7QUFJcEQsT0FBTyxFQUFFLGtCQUFrQixFQUFFLFlBQVksRUFBRSxNQUFNLDhCQUE4QixDQUFDO0FBeUhoRixNQUFNLGNBQWMsR0FBRyxDQUFDLFFBQWlCLEVBQVcsRUFBRTtJQUNwRCxNQUFNLFFBQVEsR0FBRyxrQkFBa0IsQ0FBQyxRQUFRLENBQUMsQ0FBQztJQUM5QyxPQUFPLFFBQVEsS0FBSyxJQUFJLElBQUksT0FBTyxRQUFRLENBQUMsR0FBRyxLQUFLLFFBQVEsSUFBSSxRQUFRLENBQUMsR0FBRyxDQUFDLE1BQU0sR0FBRyxDQUFDLENBQUM7QUFDMUYsQ0FBQyxDQUFDO0FBRUYsaUdBQWlHO0FBQ2pHLE1BQU0sNkJBQTZCLEdBQUcsQ0FBQyxNQUFlLEVBQUUsV0FBb0IsRUFBWSxFQUFFO0lBQ3hGLE1BQU0sTUFBTSxHQUFhLEVBQUUsQ0FBQztJQUM1QixJQUFJLEdBQW9CLENBQUM7SUFDekIsSUFBSSxPQUFPLE1BQU0sS0FBSyxRQUFRO1dBQ3pCLE1BQU0sQ0FBQyxNQUFNLEdBQUcsQ0FBQztXQUNqQixNQUFNLENBQUMsTUFBTSxJQUFJLFlBQVksQ0FBQyxnQ0FBZ0MsQ0FBQyx1QkFBdUIsRUFBRSxDQUFDO1FBQzVGLElBQUksQ0FBQztZQUNILEdBQUcsR0FBRyxJQUFJLEdBQUcsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUN4QixDQUFDO1FBQUMsTUFBTSxDQUFDO1lBQ1AsR0FBRyxHQUFHLFNBQVMsQ0FBQztRQUNsQixDQUFDO0lBQ0gsQ0FBQztJQUNELElBQUksQ0FBQyxHQUFHO1dBQ0gs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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/interfaces",
3
- "version": "32.28.0",
3
+ "version": "32.30.0",
4
4
  "private": false,
5
5
  "description": "Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.",
6
6
  "exports": {
@@ -29,8 +29,8 @@
29
29
  "devDependencies": {
30
30
  "@git.zone/tsbuild": "^5.0.0",
31
31
  "@git.zone/tsdoc": "^2.2.2",
32
- "@git.zone/tsrun": "^3.0.0",
33
- "@git.zone/tstest": "6.3.0",
32
+ "@git.zone/tsrun": "^3.0.1",
33
+ "@git.zone/tstest": "6.3.2",
34
34
  "@types/node": "26.6.2"
35
35
  },
36
36
  "files": [
package/readme.md CHANGED
@@ -482,7 +482,7 @@ canonical bytes and digest; a present member is never an empty list, so
482
482
  Operators author that authorization through `getServicePublishedPorts` and
483
483
  `setServicePublishedPorts`, modelled on the private-network membership pair.
484
484
  `IServicePublishedPorts` returns the current authorized list with its policy
485
- `revision`, or null when a service never had one. `ISetServicePublishedPorts`
485
+ `revision`, or null when a service never had a policy document. `ISetServicePublishedPorts`
486
486
  returns the accepted `IServicePublishedPorts` document with its new
487
487
  `revision` and a `replayed` flag, and carries `expectedRevision` (null for the first document), a retained
488
488
  `mutationId` for replay, and `IRuntimeNetworkPublishedPortRequest` entries of
@@ -493,6 +493,39 @@ policy meaning the service publishes nothing. Callers never supply an
493
493
  from the accepted policy revision and digest, and node-level host-port placement
494
494
  stays Cloudly's decision.
495
495
 
496
+ Every endpoint's `publicEgress` and `platformEndpointIds` come from the same
497
+ service policy document. A verified administrator authors them through
498
+ `getServiceNetworkEgress` and `setServiceNetworkEgress`
499
+ (`requests.network.IReq_Admin_Cloudly_GetServiceNetworkEgress` /
500
+ `IReq_Admin_Cloudly_SetServiceNetworkEgress`): platform endpoints are platform
501
+ authority, and only an administrator writes the runtime spec whose attached
502
+ network has to state the same values. `IServiceNetworkEgress` returns
503
+ `publicEgress` and the selected `platformEndpointIds` (sorted, unique, at most
504
+ `runtimeNetworkAuthorityContract.maximumPlatformEndpoints`) with the document's
505
+ `revision`, or null when a service never had a policy document.
506
+ `ISetServiceNetworkEgress` carries `expectedRevision` (null for the first
507
+ document), a retained `mutationId` for replay and the exact desired values, and
508
+ returns the accepted `IServiceNetworkEgress` with a `replayed` flag; Cloudly
509
+ carries the published ports forward unchanged, and refuses an id that names no
510
+ current platform endpoint. Published ports and egress are two halves of one
511
+ document with one `revision`, so `expectedRevision` of either request is the
512
+ revision the caller read from either answer, and an accepted write of either
513
+ advances it.
514
+
515
+ One document per service holds its published ports and its egress under one `revision`. The
516
+ first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
517
+ with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
518
+ no published ports. From then on both reads return it, never null. It is an explicit policy: an
519
+ attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
520
+ no public address and no platform endpoint; `network-policy-missing` means the service has no
521
+ document at all.
522
+
523
+ `snapshotServiceNetworkEgress` and `snapshotSetServiceNetworkEgress`
524
+ check the shapes. An attached runtime spec is admitted only while it states the
525
+ document's values (`network-policy-mismatch`), and nothing else grants a
526
+ workload egress: a Corestore endpoint on the node's own host is delivered
527
+ locally, but only to a workload that selects it.
528
+
496
529
  `getRuntimeNetworkProjectionPacketGrants` derives directed member connectivity
497
530
  and explicit local public/platform egress independently of DNS readiness.
498
531
  `composeRuntimeNetworkProjectionDnsViews` builds one union view per local
@@ -829,6 +862,27 @@ because the private-range rule already keeps it at /8 or longer.
829
862
  `data.runtimeNetworkVpnControlPrefix(value)` is that reader on its own, exported because a
830
863
  cluster's VPN network repeats the prefix its hub binds and has to read it exactly as the plan does.
831
864
 
865
+ Every list of a plan is stated in one canonical order, and a plan is never reordered for its
866
+ writer: `prefixes` strictly ascending by the prefix text, and `pools`, `resolvers` and
867
+ `platformEndpoints` strictly ascending by `id`. Both compare as strings, so the order is
868
+ lexicographic, not numeric — `100.64.0.0/10` comes before `20.0.0.0/8`, and `resolver-10` before
869
+ `resolver-9` — and a value stated twice breaks it as well. A list out of order is the one plan
870
+ refusal the contract names: `snapshotRuntimeNetworkAddressPlan` (and
871
+ `admitRuntimeNetworkAddressPlanChange` through it) throws
872
+ `data.RuntimeNetworkAddressPlanStructureError`, whose `violation` is
873
+ `{ field: 'prefixes' | 'pools' | 'resolvers' | 'platformEndpoints', rule: 'order' }` and whose
874
+ message quotes nothing from the plan. Every other refusal stays the contract's generic rejection.
875
+
876
+ ```typescript
877
+ try {
878
+ data.snapshotRuntimeNetworkAddressPlan(plan);
879
+ } catch (error) {
880
+ if (error instanceof data.RuntimeNetworkAddressPlanStructureError) {
881
+ error.violation; // { field: 'prefixes', rule: 'order' }
882
+ }
883
+ }
884
+ ```
885
+
832
886
  `data.IRuntimeNetworkVpnHub { clusterId, address, quicPort }` is one cluster's hub as its relay
833
887
  bound it. `runtimeNetworkVpnHubEndpoints(hub)` is the one derivation of what it contributes:
834
888
  `<clusterId>:quic` on `udp`, one endpoint per row of
@@ -868,7 +922,9 @@ outlive a plan revision, so it answers:
868
922
  reference check as removing it.
869
923
 
870
924
  `TRuntimeNetworkAddressPlanRefusal` adds `plan-resolver-referenced` and `plan-endpoint-referenced`,
871
- which only Cloudly can decide against live references, so every plan refusal has one type.
925
+ which only Cloudly can decide against live references, and `plan-unsorted`, which Cloudly answers
926
+ for a `RuntimeNetworkAddressPlanStructureError` with the list as its reference, so every plan
927
+ refusal has one type.
872
928
  `requests.network` carries `setRuntimeNetworkAddressPlan { identity, expectedRevision, plan }`
873
929
  (`expectedRevision: null` when no plan exists yet) and `getRuntimeNetworkAddressPlan`, which answers
874
930
  `null` until the first plan is set.
@@ -1431,19 +1487,39 @@ evidence has not arrived). `refused` carries a `TServiceRuntimeRefusal`:
1431
1487
  `network-workload-pool-missing`, `network-workload-pool-exhausted`, `volume-driver-unsupported`,
1432
1488
  `volume-options-unsupported`, `volume-shared-unsupported`, `volume-placement-unsupported`,
1433
1489
  `volume-initialization-unknown`, `volume-ownership-unknown`, `objectstorage-endpoint-missing`,
1434
- `objectstorage-binding-invalid` or `objectstorage-environment-conflict`. The first four volume
1490
+ `objectstorage-binding-invalid`, `objectstorage-environment-conflict`,
1491
+ `objectstorage-endpoint-unselected`, `database-endpoint-missing` or `database-endpoint-unselected`.
1492
+ The first four volume
1435
1493
  refusals name what a local storage claim cannot express: a volume driver other than a node-local
1436
1494
  directory, driver or mount options, one volume mounted by more than one replica or service, and a
1437
1495
  placement that does not pin each volume to one node. The other two name what Cloudly will not guess,
1438
- see [Service Volume Initialization](#service-volume-initialization). The three object-storage
1439
- refusals name why a binding's environment cannot be composed, see
1440
- [Object-Storage Binding Environment](#object-storage-binding-environment). `ports-too-many` is a service declaring more target ports than the
1496
+ see [Service Volume Initialization](#service-volume-initialization). Of the four object-storage
1497
+ refusals, `objectstorage-endpoint-missing`, `objectstorage-binding-invalid` and
1498
+ `objectstorage-environment-conflict` name why a binding's environment cannot be composed, see
1499
+ [Object-Storage Binding Environment](#object-storage-binding-environment), and
1500
+ `objectstorage-endpoint-unselected`, like `database-endpoint-unselected`, names an endpoint the
1501
+ workload's network does not select (below). `ports-too-many` is a service declaring more target ports than the
1441
1502
  `runtimeConfigContract.maximumTargetPorts` (64) a config carries, where `port-protocol-unsupported`
1442
1503
  is about one port's protocol; `node-authority-unsupported` is a node whose session resolved fewer
1443
1504
  workload authorities than the spec needs, refused at the admit step rather than handed to a node
1444
- that would silently park it. An attached network requires an explicit service policy: its absence
1505
+ that would silently park it. An attached network requires an explicit service policy
1506
+ (`IServiceNetworkEgress`): its absence
1445
1507
  is `network-policy-missing`, and a disagreement with the requested egress or platform endpoints is
1446
- `network-policy-mismatch`. A missing workload address pool in the current protected authority is
1508
+ `network-policy-mismatch`. A binding endpoint the workload's network does not select would leave its
1509
+ connection dark, so it is refused instead: `database-endpoint-unselected` for an enabled database
1510
+ binding whose node's valid `corestoreDatabase` is not selected, and `objectstorage-endpoint-unselected`
1511
+ for an enabled object-storage binding whose node's valid `corestoreObjectStorage` is not. An endpoint
1512
+ is selected when one of the spec's `platformEndpointIds` names a platform endpoint of the current
1513
+ protected authority with the endpoint's host as its `address`, `tcp` as its `protocol` and the
1514
+ endpoint's port as its `port` — for object storage the origin's port, or 80 for `http:` and 443 for
1515
+ `https:` when it states none. An `isolated` network selects nothing, and platform endpoints are
1516
+ IPv4, so a DNS-name or IPv6 host is never selected. Both are judged only for a valid endpoint: a node
1517
+ stating none, or an invalid one, stays `database-endpoint-missing` or `objectstorage-endpoint-missing`.
1518
+ `platform.findUnselectedCorestoreEndpoints({ database, objectStorage, platformEndpoints, network })`
1519
+ is that rule: given one node's `corestoreDatabase` and `corestoreObjectStorage` (each `null` when no
1520
+ enabled binding of that kind is placed on the node), the current protected authority's
1521
+ `platformEndpoints` and the spec's `network`, it returns the `TCorestoreEndpointSelectionRefusal`s
1522
+ that apply, sorted, or an empty list. A missing workload address pool in the current protected authority is
1447
1523
  `network-workload-pool-missing`; existing eligible pools without a free workload subnet are
1448
1524
  `network-workload-pool-exhausted`. Neither policy nor pool is silently defaulted. A refusal never
1449
1525
  retires running slots.
@@ -2319,9 +2395,13 @@ with `clusterRelayCertificateDomainName(relay)`, which returns the origin's host
2319
2395
  Cloudly issues the certificate for and owns the A record for. An origin naming an address literal
2320
2396
  therefore has no issuable certificate.
2321
2397
 
2322
- `data.clusterRelayUndispatchedRefusals` names the four registration refusals that end a relay's
2323
- custody — `relay-cluster-not-enabled`, `relay-node-foreign`, `relay-sequence-not-advancing` and
2324
- `relay-credential-generation-stale` — with `data.TClusterRelayUndispatchedRefusal` as their union.
2398
+ `data.clusterRelayUndispatchedRefusals` names the five registration refusals that end a relay's
2399
+ custody — `relay-cluster-not-enabled`, `relay-node-foreign`, `relay-node-unknown`,
2400
+ `relay-sequence-not-advancing` and `relay-credential-generation-stale` — with
2401
+ `data.TClusterRelayUndispatchedRefusal` as their union. `relay-node-foreign` names a node of another
2402
+ cluster; `relay-node-unknown` names a node Cloudly does not hold at all, because no Spark
2403
+ enrollment has created it yet, so the node a relay's `COREFLOW_NODE_ID` names is enrolled before
2404
+ the relay registers.
2325
2405
  Each is Cloudly reading its own records and stating that it does not dispatch to this relay, so a
2326
2406
  relay that hears one drops what it holds for its nodes instead of waiting. A refusal that only
2327
2407
  says Cloudly could not verify the relay states nothing about dispatch and is deliberately not in
@@ -2941,7 +3021,62 @@ const provisioning: requests.gateway.IReq_ProvisionGatewayClientCredential['requ
2941
3021
  };
2942
3022
  ```
2943
3023
 
2944
- `getGatewayClientContext` returns effective live policy. A `gatewayClient` role necessarily includes the credential ID, bound client ID/type, and `policyGeneration`; consumers should reject admin/operator or mismatched contexts rather than falling back to a caller-supplied owner ID. `getGatewayClientMailOverview` provides an owner-scoped domain and recent-message summary. `getGatewayClientMailDomainCount` derives ownership exclusively from the authenticating gateway credential and returns `{ count: number }` for its distinct configured mail domains. Cloudly therefore uses only `dcrouterGatewayApiToken`; the former `dcrouterOpsApiToken` setting is not part of `data.ICloudlySettings`.
3024
+ `getGatewayClientContext` returns effective live policy. A `gatewayClient` role necessarily includes the credential ID, bound client ID/type, and `policyGeneration`; consumers should reject admin/operator or mismatched contexts rather than falling back to a caller-supplied owner ID. `getGatewayClientMailOverview` provides an owner-scoped domain and recent-message summary. `getGatewayClientMailDomainCount` derives ownership exclusively from the authenticating gateway credential and returns `{ count: number }` for its distinct configured mail domains. Cloudly therefore holds only its gateway-client credential, as the system secret `DCROUTER_GATEWAY_API_TOKEN` (see Cloudly Gateway Credential Bootstrap below); neither it nor the former `dcrouterOpsApiToken` is part of `data.ICloudlySettings`.
3025
+
3026
+ ### Cloudly Gateway Credential Bootstrap
3027
+
3028
+ Cloudly enrolls itself at dcrouter as one least-privilege gateway client. The only way a gateway
3029
+ credential enters Cloudly is a dcrouter admin token an administrator hands in once, for the first
3030
+ enrollment and for every re-bootstrap a stale policy asks for — a changed gateway setting, or a
3031
+ runtime network address plan whose DNS record allowance moved.
3032
+
3033
+ - `requests.settings.IReq_Any_Cloudly_BootstrapExternalGatewayCredential`
3034
+ (`bootstrapExternalGatewayCredential`): `{ identity, expectedGatewayUrl, expectedGatewayClientId,
3035
+ bootstrapToken: { mode: 'sealed', envelope } }` → `{ state }`. The token is sealed to Cloudly's
3036
+ active secret ingress recipient (`requests.secret.IReq_GetSecretIngressRecipient`) under
3037
+ `data.createGatewayBootstrapEnvelopeContext({ gatewayUrl, gatewayClientId })`, so it opens only
3038
+ for a request naming the same dcrouter URL (HTTPS only, since an admin token is on the wire) and
3039
+ client id, and Cloudly compares both with its own
3040
+ settings before the token leaves it. Cloudly asks dcrouter whether the token is an admin token,
3041
+ provisions, finalizes and stores its own gateway-client credential with it, and answers the
3042
+ enrollment that results. The admin token is used for that one call and never stored; revoke it at
3043
+ dcrouter afterwards. A refusal leaves the held credential unchanged and is one of
3044
+ `data.externalGatewayBootstrapRefusals`: `gateway-unconfigured`, `gateway-target-mismatch`,
3045
+ `bootstrap-token-invalid`, `bootstrap-token-rejected`, `bootstrap-token-not-admin`,
3046
+ `bootstrap-candidate-pending`, `gateway-unreachable` and `bootstrap-provisioning-refused`.
3047
+ - `requests.settings.IReq_Any_Cloudly_GetExternalGatewayEnrollmentState`
3048
+ (`getExternalGatewayEnrollmentState`): `{ identity }` → `{ state }`.
3049
+
3050
+ `data.IExternalGatewayEnrollmentState { status, reason?, lastCheckedAt?, nextRetryAt?,
3051
+ cachedValidationProof, finalizationPending }` never carries a credential. `status` is one of
3052
+ `data.externalGatewayEnrollmentStatuses`: `unconfigured`, `checking`, `ready`, `degraded` (dcrouter
3053
+ unreachable; Cloudly retries) and `admin-bootstrap-required`. Both sides judge values with
3054
+ `requests.settings.validateBootstrapExternalGatewayCredentialRequest(request, recipient)` (schema,
3055
+ recipient, context digest and the ciphertext bound, without opening anything),
3056
+ `requests.settings.validateGetExternalGatewayEnrollmentStateRequest`,
3057
+ `data.validateExternalGatewayEnrollmentState` and `data.isExternalGatewayBootstrapToken(bytes)`,
3058
+ which accepts 1 to 1024 bytes of visible ASCII (`data.externalGatewayBootstrapContract`).
3059
+
3060
+ ```typescript
3061
+ import * as smartcrypto from '@push.rocks/smartcrypto';
3062
+ import { data, requests } from '@serve.zone/interfaces';
3063
+
3064
+ const target = { gatewayUrl: 'https://gateway.example.com', gatewayClientId: 'cloudly.example.com' };
3065
+ const request: requests.settings.IReq_Any_Cloudly_BootstrapExternalGatewayCredential['request'] = {
3066
+ identity: { jwt: adminJwt },
3067
+ expectedGatewayUrl: target.gatewayUrl,
3068
+ expectedGatewayClientId: target.gatewayClientId,
3069
+ bootstrapToken: {
3070
+ mode: 'sealed',
3071
+ envelope: await smartcrypto.sealX25519Envelope({
3072
+ plaintext: adminTokenBytes,
3073
+ recipientPublicKey: ingressPublicKey,
3074
+ recipientKeyId: ingressRecipient.recipientKeyId,
3075
+ context: data.createGatewayBootstrapEnvelopeContext(target),
3076
+ }),
3077
+ },
3078
+ };
3079
+ ```
2945
3080
 
2946
3081
  ### Gateway Client Hostname Records and Certificates
2947
3082
 
@@ -3642,6 +3777,10 @@ object-storage endpoint. What they are addressed to is a fact about the node, ow
3642
3777
  - `TServiceRuntimeRefusal` gains `database-endpoint-missing`: an enabled database binding placed on
3643
3778
  a node that states no valid `corestoreDatabase` is neither granted nor published, and its workload
3644
3779
  is refused rather than started with a guessed host.
3780
+ - A workload reaches that endpoint only when its attached network selects it as a platform endpoint;
3781
+ an unselected valid endpoint is refused as `database-endpoint-unselected` (see
3782
+ [Service Runtime Specs And Status](#service-runtime-specs-and-status) for the matching rule), as an object-storage endpoint is as
3783
+ `objectstorage-endpoint-unselected`.
3645
3784
 
3646
3785
  ### Launcher Environment Delivery
3647
3786
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.28.0',
6
+ version: '32.30.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -175,6 +175,11 @@ export const clusterRelayUndispatchedRefusals = Object.freeze([
175
175
  'relay-cluster-not-enabled',
176
176
  /** The node the relay names belongs to another cluster than the one its identity names. */
177
177
  'relay-node-foreign',
178
+ /**
179
+ * The node the relay names does not exist: no Spark enrollment has created it yet. The relay's
180
+ * `COREFLOW_NODE_ID` must name a node Cloudly already holds, so the node is enrolled first.
181
+ */
182
+ 'relay-node-unknown',
178
183
  /** A registration of this relay identity at the same or a higher sequence is already recorded. */
179
184
  'relay-sequence-not-advancing',
180
185
  /**
@@ -4,7 +4,15 @@ import { computeRuntimeNetworkProtectedAuthorityDigest, runtimeNetworkAuthorityC
4
4
  snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
5
5
  import { captureNetwork, failNetwork, networkContains, networkCounter, networkEqual, networkId, networkIpv4, networkList,
6
6
  networkOverlaps, networkPrefix, networkPrefixBounds, networkPrivatePrefix, networkRecord, networkSafe,
7
- requireNetwork } from '../private/networkcontract.js';
7
+ requireNetwork, RuntimeNetworkAddressPlanStructureError, type TRuntimeNetworkAddressPlanOrderedField,
8
+ } from '../private/networkcontract.js';
9
+
10
+ export { RuntimeNetworkAddressPlanStructureError } from '../private/networkcontract.js';
11
+ export type {
12
+ IRuntimeNetworkAddressPlanStructureViolation,
13
+ TRuntimeNetworkAddressPlanOrderedField,
14
+ TRuntimeNetworkAddressPlanStructureRule,
15
+ } from '../private/networkcontract.js';
8
16
 
9
17
  /**
10
18
  * The address space an administrator gives the runtime network, and the one input the protected
@@ -15,11 +23,25 @@ export interface IRuntimeNetworkAddressPlan {
15
23
  id: 'address-plan';
16
24
  /** Compare-and-swap counter; every accepted change advances it by one. */
17
25
  revision: number;
18
- /** Every protected prefix: they cover the pools, resolvers, platform endpoints and the VPN. */
26
+ /**
27
+ * Every protected prefix: they cover the pools, resolvers, platform endpoints and the VPN.
28
+ *
29
+ * Stated in strictly ascending string order of the prefix text. The order is lexicographic, not
30
+ * numeric: `10.10.0.0/16` comes before `10.2.0.0/16`, and `100.64.0.0/10` before `20.0.0.0/8`.
31
+ * A list out of that order, or one naming a prefix twice, is refused with
32
+ * `RuntimeNetworkAddressPlanStructureError` `{ field: 'prefixes', rule: 'order' }`.
33
+ */
19
34
  prefixes: string[];
35
+ /** Stated in strictly ascending string order of `id`; otherwise `{ field: 'pools', rule: 'order' }`. */
20
36
  pools: IRuntimeNetworkProtectedAuthority['pools'];
37
+ /** Stated in strictly ascending string order of `id`; otherwise `{ field: 'resolvers', rule: 'order' }`. */
21
38
  resolvers: IRuntimeNetworkProtectedAuthority['resolvers'];
22
- /** Without the cluster hubs, which the composed authority adds from the hubs it is given. */
39
+ /**
40
+ * Without the cluster hubs, which the composed authority adds from the hubs it is given.
41
+ *
42
+ * Stated in strictly ascending string order of `id`; otherwise
43
+ * `{ field: 'platformEndpoints', rule: 'order' }`.
44
+ */
23
45
  platformEndpoints: IRuntimeNetworkProtectedAuthority['platformEndpoints'];
24
46
  vpn: {
25
47
  /** The private prefix VPN control addresses are allocated from. */
@@ -63,7 +85,12 @@ export type TRuntimeNetworkAddressPlanChangeRefusal = 'plan-vpn-immutable' | 'pl
63
85
  * platform endpoint the change removes or changes while a router selection or a service still names it.
64
86
  */
65
87
  export type TRuntimeNetworkAddressPlanRefusal =
66
- | TRuntimeNetworkAddressPlanChangeRefusal | 'plan-resolver-referenced' | 'plan-endpoint-referenced';
88
+ | TRuntimeNetworkAddressPlanChangeRefusal | 'plan-resolver-referenced' | 'plan-endpoint-referenced'
89
+ /**
90
+ * A list of the plan is not in its canonical order; `RuntimeNetworkAddressPlanStructureError`
91
+ * names which one, and a refusal carries that field as its reference.
92
+ */
93
+ | 'plan-unsorted';
67
94
 
68
95
  export type TRuntimeNetworkAddressPlanChange =
69
96
  | { status: 'unchanged' }
@@ -180,14 +207,49 @@ const requireVpnProtected = (authority: IRuntimeNetworkProtectedAuthority, vpn:
180
207
  && !networkOverlaps(vpn.controlPrefix, vpn.hubPrefix));
181
208
  };
182
209
 
210
+ /** The key each ordered list of a plan is sorted by, or `undefined` for an entry no key can be read from. */
211
+ const planOrderKeys: Record<TRuntimeNetworkAddressPlanOrderedField, (entry: unknown) => string | undefined> = {
212
+ prefixes: entry => typeof entry === 'string' ? entry : undefined,
213
+ pools: entry => entryId(entry),
214
+ resolvers: entry => entryId(entry),
215
+ platformEndpoints: entry => entryId(entry),
216
+ };
217
+
218
+ function entryId(entry: unknown): string | undefined {
219
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
220
+ const id = (entry as Record<string, unknown>).id;
221
+ return typeof id === 'string' ? id : undefined;
222
+ }
223
+
224
+ /**
225
+ * Refuses a plan list out of its canonical order by name: strictly ascending by the list's key, in
226
+ * JavaScript string order, which is the order the protected authority contract reads it in. A list
227
+ * whose keys cannot all be read is left to the structural check, which refuses it without a name.
228
+ */
229
+ const requirePlanOrder = (item: Record<string, unknown>): void => {
230
+ for (const field of ['prefixes', 'pools', 'resolvers', 'platformEndpoints'] as const) {
231
+ const list = item[field];
232
+ if (!Array.isArray(list)) continue;
233
+ const keys = list.map(planOrderKeys[field]);
234
+ if (keys.some(key => key === undefined)) continue;
235
+ if (!keys.every((key, index) => index === 0 || (keys[index - 1] as string) < (key as string))) {
236
+ throw new RuntimeNetworkAddressPlanStructureError({ field, rule: 'order' });
237
+ }
238
+ }
239
+ };
240
+
183
241
  /**
184
242
  * Structural snapshot for exact persistence and for a plan write. The plan's own sets pass the
185
243
  * protected authority's own validator, so no plan can describe an authority that contract refuses,
186
244
  * and both VPN prefixes are reserved inside the protected space the plan states.
245
+ *
246
+ * A list out of its canonical order is refused with `RuntimeNetworkAddressPlanStructureError`,
247
+ * which names the list; every other refusal is the contract's generic rejection.
187
248
  */
188
249
  export const snapshotRuntimeNetworkAddressPlan = (value: unknown): IRuntimeNetworkAddressPlan => networkSafe(() => {
189
250
  const item = networkRecord(captureNetwork(value), ['id', 'revision', 'prefixes', 'pools', 'resolvers', 'platformEndpoints', 'vpn']);
190
251
  requireNetwork(item.id === runtimeNetworkAddressPlanContract.id);
252
+ requirePlanOrder(item);
191
253
  const own = authorityOf({ prefixes: item.prefixes, pools: item.pools, resolvers: item.resolvers,
192
254
  platformEndpoints: item.platformEndpoints }, validationFrame);
193
255
  const plan: IRuntimeNetworkAddressPlan = { id: runtimeNetworkAddressPlanContract.id, revision: networkCounter(item.revision),