@serve.zone/interfaces 32.41.0 → 32.43.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 (36) hide show
  1. package/changelog.md +72 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/corestorecontroltoken.d.ts +90 -0
  4. package/dist_ts/data/corestorecontroltoken.js +106 -0
  5. package/dist_ts/data/index.d.ts +2 -0
  6. package/dist_ts/data/index.js +3 -1
  7. package/dist_ts/data/networkaddressplan.js +3 -12
  8. package/dist_ts/data/networkauthority.d.ts +16 -0
  9. package/dist_ts/data/networkauthority.js +45 -1
  10. package/dist_ts/data/networkcredential.d.ts +12 -8
  11. package/dist_ts/data/networkcredential.js +18 -13
  12. package/dist_ts/data/networkegressretirement.d.ts +129 -0
  13. package/dist_ts/data/networkegressretirement.js +142 -0
  14. package/dist_ts/data/noderetirement.d.ts +24 -4
  15. package/dist_ts/data/noderetirement.js +20 -3
  16. package/dist_ts/private/networkcontract.d.ts +2 -0
  17. package/dist_ts/private/networkcontract.js +10 -1
  18. package/dist_ts/requests/corestorecontrol.d.ts +33 -0
  19. package/dist_ts/requests/corestorecontrol.js +2 -0
  20. package/dist_ts/requests/index.d.ts +2 -1
  21. package/dist_ts/requests/index.js +3 -2
  22. package/dist_ts/requests/node.d.ts +9 -1
  23. package/package.json +4 -4
  24. package/readme.md +167 -20
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/data/corestorecontroltoken.ts +195 -0
  27. package/ts/data/index.ts +2 -0
  28. package/ts/data/networkaddressplan.ts +3 -13
  29. package/ts/data/networkauthority.ts +46 -0
  30. package/ts/data/networkcredential.ts +17 -12
  31. package/ts/data/networkegressretirement.ts +231 -0
  32. package/ts/data/noderetirement.ts +41 -5
  33. package/ts/private/networkcontract.ts +9 -0
  34. package/ts/requests/corestorecontrol.ts +43 -0
  35. package/ts/requests/index.ts +2 -0
  36. package/ts/requests/node.ts +9 -1
@@ -0,0 +1,195 @@
1
+ import { isCorestoreControlToken } from '../runtime.corestore.js';
2
+ import {
3
+ captureNetwork,
4
+ networkCounter,
5
+ networkId,
6
+ networkList,
7
+ networkRecord,
8
+ requireNetwork,
9
+ } from '../private/networkcontract.js';
10
+
11
+ /**
12
+ * Whether Cloudly holds the Corestore control token, and at which revision. Value-free.
13
+ *
14
+ * There is one token for both Corestore provider configs, so this is one state rather than one per
15
+ * provider. `revision` is 1 for the first token ever stored and advances by exactly one with every
16
+ * accepted set, whichever path set it. No request removes a token, so a present token never turns
17
+ * absent again.
18
+ */
19
+ export type TCorestoreControlTokenState =
20
+ | { state: 'absent' }
21
+ | { state: 'present'; revision: number };
22
+
23
+ /**
24
+ * What an administrator names to store the Corestore control token: the token, and the state the
25
+ * administrator last read. Never a minimum: a set that committed after that read is refused rather
26
+ * than overwritten.
27
+ *
28
+ * `token` is the secret itself, the only member of this contract that carries it. A sender never
29
+ * logs, hooks or persists the request; Cloudly never answers or records the token.
30
+ */
31
+ export interface ICorestoreControlTokenSetRequest {
32
+ token: string;
33
+ expected: TCorestoreControlTokenState;
34
+ }
35
+
36
+ /**
37
+ * Which path stored a token: an administrator's typed request, or the `secrets
38
+ * set-corestore-control-token` command run inside Cloudly's container.
39
+ */
40
+ export type TCorestoreControlTokenSetPath = 'admin-request' | 'container-cli';
41
+
42
+ /**
43
+ * The audit record of one accepted set, which Cloudly writes in the transaction that stores the
44
+ * token. Append-only and value-free: it names which revision was stored, by whom, how and when,
45
+ * never the token or a hash of it.
46
+ */
47
+ export interface ICorestoreControlTokenSetReceipt {
48
+ id: string;
49
+ /** The revision this set stored. */
50
+ revision: number;
51
+ via: TCorestoreControlTokenSetPath;
52
+ /** The administrator read from the verified credential, or `operator:secrets-cli`. */
53
+ actorId: string;
54
+ setAt: number;
55
+ }
56
+
57
+ /** What Cloudly answers to an accepted set: the state it produced and its receipt. Value-free. */
58
+ export interface ICorestoreControlTokenSetResult {
59
+ state: { state: 'present'; revision: number };
60
+ receipt: ICorestoreControlTokenSetReceipt;
61
+ }
62
+
63
+ /**
64
+ * The token's state and its most recent receipts, oldest first: every receipt while the token has
65
+ * been set at most `corestoreControlTokenMaximumReceipts` times, the latest that many after. Value-free.
66
+ */
67
+ export interface ICorestoreControlTokenStateRead {
68
+ state: TCorestoreControlTokenState;
69
+ receipts: ICorestoreControlTokenSetReceipt[];
70
+ }
71
+
72
+ /** Every reason Cloudly answers instead of storing a Corestore control token. */
73
+ export const corestoreControlTokenSetRefusals = Object.freeze([
74
+ /** The token is outside the contract: its length, encoding or whitespace (`isCorestoreControlToken`). */
75
+ 'token-invalid',
76
+ /** `expected` said absent, and a token is stored. */
77
+ 'token-present',
78
+ /** `expected` named a revision, and no token is stored. */
79
+ 'token-absent',
80
+ /** `expected` named a revision, and the stored token is at another one. */
81
+ 'token-revision-mismatch',
82
+ /** A Corestore provider config is missing or disabled, so there is nothing to store it under. */
83
+ 'provider-unavailable',
84
+ ] as const);
85
+
86
+ export type TCorestoreControlTokenSetRefusal = typeof corestoreControlTokenSetRefusals[number];
87
+
88
+ /** The actor a receipt names when the in-container command stored the token. */
89
+ export const corestoreControlTokenContainerCliActorId = 'operator:secrets-cli' as const;
90
+
91
+ /** The most receipts one state read answers: the latest ones, once a token has been set more often. */
92
+ export const corestoreControlTokenMaximumReceipts = 4096;
93
+
94
+ const fail = (): never => { throw new Error('Corestore control token contract rejected.'); };
95
+ const safe = <T>(operationArg: () => T): T => {
96
+ try { return operationArg(); } catch { return fail(); }
97
+ };
98
+
99
+ const readState = (valueArg: unknown): TCorestoreControlTokenState => {
100
+ const value = networkRecord(valueArg, ['state'], ['revision']);
101
+ if (value.state === 'absent') {
102
+ networkRecord(value, ['state']);
103
+ return { state: 'absent' };
104
+ }
105
+ requireNetwork(value.state === 'present');
106
+ return { state: 'present', revision: networkCounter(networkRecord(value, ['state', 'revision']).revision) };
107
+ };
108
+
109
+ export const snapshotCorestoreControlTokenState = (
110
+ valueArg: unknown,
111
+ ): TCorestoreControlTokenState => safe(() => readState(captureNetwork(valueArg)));
112
+
113
+ /**
114
+ * Structural copy of a set request. A refusal never quotes the request, because it carries the
115
+ * token: every failure is the contract's one value-free message.
116
+ */
117
+ export const snapshotCorestoreControlTokenSetRequest = (
118
+ valueArg: unknown,
119
+ ): ICorestoreControlTokenSetRequest => safe(() => {
120
+ const value = networkRecord(captureNetwork(valueArg), ['token', 'expected']);
121
+ requireNetwork(isCorestoreControlToken(value.token));
122
+ return { token: value.token, expected: readState(value.expected) };
123
+ });
124
+
125
+ const readReceipt = (valueArg: unknown): ICorestoreControlTokenSetReceipt => {
126
+ const value = networkRecord(valueArg, ['id', 'revision', 'via', 'actorId', 'setAt']);
127
+ requireNetwork(value.via === 'admin-request' || value.via === 'container-cli');
128
+ const actorId = networkId(value.actorId);
129
+ requireNetwork(value.via === 'admin-request'
130
+ ? actorId !== corestoreControlTokenContainerCliActorId
131
+ : actorId === corestoreControlTokenContainerCliActorId);
132
+ return {
133
+ id: networkId(value.id),
134
+ revision: networkCounter(value.revision),
135
+ via: value.via,
136
+ actorId,
137
+ setAt: networkCounter(value.setAt),
138
+ };
139
+ };
140
+
141
+ export const snapshotCorestoreControlTokenSetReceipt = (
142
+ valueArg: unknown,
143
+ ): ICorestoreControlTokenSetReceipt => safe(() => readReceipt(captureNetwork(valueArg)));
144
+
145
+ export const snapshotCorestoreControlTokenSetResult = (
146
+ valueArg: unknown,
147
+ ): ICorestoreControlTokenSetResult => safe(() => {
148
+ const value = networkRecord(captureNetwork(valueArg), ['state', 'receipt']);
149
+ const state = readState(value.state);
150
+ requireNetwork(state.state === 'present');
151
+ return { state, receipt: readReceipt(value.receipt) };
152
+ });
153
+
154
+ /**
155
+ * Structural copy of a state read. No token stored means no receipt. A stored token's receipts are
156
+ * contiguous and end at its revision, and they start at revision 1 -- or at 2, because only a token
157
+ * stored before receipts existed reaches revision 1 without one -- unless the read is a full window
158
+ * of `corestoreControlTokenMaximumReceipts`, whose oldest receipt may name any later revision.
159
+ */
160
+ export const snapshotCorestoreControlTokenStateRead = (
161
+ valueArg: unknown,
162
+ ): ICorestoreControlTokenStateRead => safe(() => {
163
+ const value = networkRecord(captureNetwork(valueArg), ['state', 'receipts']);
164
+ const state = readState(value.state);
165
+ const receipts = networkList(value.receipts, corestoreControlTokenMaximumReceipts, readReceipt);
166
+ if (state.state === 'absent') {
167
+ requireNetwork(receipts.length === 0);
168
+ return { state, receipts };
169
+ }
170
+ const first = state.revision - receipts.length + 1;
171
+ requireNetwork(first >= 1
172
+ && (first <= 2 || receipts.length === corestoreControlTokenMaximumReceipts)
173
+ && receipts.every((receipt, index) => receipt.revision === first + index));
174
+ return { state, receipts };
175
+ });
176
+
177
+ /**
178
+ * Joins a set answer to the request it answers. The answered state is the one the request
179
+ * produces -- revision 1 when no token was stored, the expected revision plus one otherwise -- and
180
+ * the receipt is an administrator's receipt for exactly that revision. Content join only: whether
181
+ * the token was stored is Cloudly's to prove, and the answer never carries it.
182
+ */
183
+ export const bindCorestoreControlTokenSet = (
184
+ resultArg: unknown,
185
+ requestArg: unknown,
186
+ ): ICorestoreControlTokenSetResult => safe(() => {
187
+ const request = snapshotCorestoreControlTokenSetRequest(requestArg);
188
+ const result = snapshotCorestoreControlTokenSetResult(resultArg);
189
+ const produced = request.expected.state === 'absent' ? 1 : request.expected.revision + 1;
190
+ requireNetwork(Number.isSafeInteger(produced)
191
+ && result.state.revision === produced
192
+ && result.receipt.revision === produced
193
+ && result.receipt.via === 'admin-request');
194
+ return result;
195
+ });
package/ts/data/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './clusteringressdesignation.js';
7
7
  export * from './clusterrelaycredential.js';
8
8
  export * from './config.js';
9
9
  export * from './corestore.js';
10
+ export * from './corestorecontroltoken.js';
10
11
  export * from './corestorelegacyadoption.js';
11
12
  export * from './coremail.js';
12
13
  export * from './coremail.runtime.js';
@@ -38,6 +39,7 @@ export * from './networkaddressplan.js';
38
39
  export * from './networkauthority.js';
39
40
  export * from './networkcredential.js';
40
41
  export * from './networkdelivery.js';
42
+ export * from './networkegressretirement.js';
41
43
  export * from './networklease.js';
42
44
  export * from './networknode.js';
43
45
  export * from './networkprojection.js';
@@ -2,8 +2,8 @@ import type { TSha256Digest } from './immutableimage.js';
2
2
  import type { IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
3
3
  import { computeRuntimeNetworkProtectedAuthorityDigest, runtimeNetworkAuthorityContract,
4
4
  snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
5
- import { captureNetwork, failNetwork, networkContains, networkCounter, networkEqual, networkId, networkIpv4, networkList,
6
- networkOverlaps, networkPrefix, networkPrefixBounds, networkPrivatePrefix, networkRecord, networkSafe,
5
+ import { captureNetwork, failNetwork, networkContains, networkCounter, networkCoveredBy, networkEqual, networkId, networkIpv4, networkList,
6
+ networkOverlaps, networkPrefix, networkPrivatePrefix, networkRecord, networkSafe,
7
7
  requireNetwork, RuntimeNetworkAddressPlanStructureError, type TRuntimeNetworkAddressPlanOrderedField,
8
8
  } from '../private/networkcontract.js';
9
9
 
@@ -279,16 +279,6 @@ export const composeRuntimeNetworkProtectedAuthority = async (
279
279
  } catch { return failNetwork(); }
280
280
  };
281
281
 
282
- /** Whether the union of `prefixes`, which never overlap one another, covers every address of `prefix`. */
283
- const coveredBy = (prefixes: string[], prefix: string): boolean => {
284
- const [first, last] = networkPrefixBounds(prefix);
285
- const covered = prefixes.reduce((sum, candidate) => {
286
- const [low, high] = networkPrefixBounds(candidate);
287
- return sum + Math.max(0, Math.min(last, high) - Math.max(first, low) + 1);
288
- }, 0);
289
- return covered === last - first + 1;
290
- };
291
-
292
282
  /**
293
283
  * The entries of `previous` that `next` does not hold exactly as they were. An entry changed under its
294
284
  * old id counts: whatever still names that id would now reach something else.
@@ -314,7 +304,7 @@ export const admitRuntimeNetworkAddressPlanChange = (previousValue: unknown | nu
314
304
  if (networkEqual({ ...previous, revision: next.revision }, next)) return { status: 'unchanged' };
315
305
  if (!networkEqual(previous.vpn, next.vpn)) return { status: 'refused', reason: 'plan-vpn-immutable' };
316
306
  if (removedOrChanged(previous.pools, next.pools).length > 0) return { status: 'refused', reason: 'plan-pool-removed' };
317
- if (!previous.prefixes.every(prefix => coveredBy(next.prefixes, prefix))) return { status: 'refused', reason: 'plan-prefix-shrunk' };
307
+ if (!previous.prefixes.every(prefix => networkCoveredBy(next.prefixes, prefix))) return { status: 'refused', reason: 'plan-prefix-shrunk' };
318
308
  return { status: 'accepted', removed: { resolverIds: idsOf(removedOrChanged(previous.resolvers, next.resolvers)),
319
309
  platformEndpointIds: idsOf(removedOrChanged(previous.platformEndpoints, next.platformEndpoints)) } };
320
310
  });
@@ -249,6 +249,52 @@ export const computeRuntimeNetworkProtectedAuthorityDigest = (value: IRuntimeNet
249
249
  export const computeRuntimeNetworkHandoffLeaseDigest = (value: IRuntimeNetworkHandoffLease): Promise<TSha256Digest> =>
250
250
  computeDigest(runtimeNetworkAuthorityContract.handoffDigestDomain, snapshotRuntimeNetworkHandoffLease(value));
251
251
 
252
+ /** Every address of `prefix` lies in one of `cover`, which must not overlap itself. */
253
+ const covered = (prefix: string, cover: string[]): boolean => {
254
+ const [first, last] = bounds(prefix);
255
+ let held = 0;
256
+ for (const range of cover) {
257
+ const [low, high] = bounds(range);
258
+ if (low <= last && first <= high) held += Math.min(last, high) - Math.max(first, low) + 1;
259
+ }
260
+ return held === last - first + 1;
261
+ };
262
+ /** Every entry of `previous` is in `next` with exactly the same content, matched by `key`. */
263
+ const retains = <T>(previous: T[], next: T[], key: (value: T) => string): boolean =>
264
+ previous.every(entry => next.some(candidate => key(candidate) === key(entry) && equals(candidate, entry)));
265
+
266
+ /**
267
+ * Whether `next` continues the protection `previous` states without taking anything from it:
268
+ * `next` is `previous` itself, or exactly the next step of its chain (the same `id` and controller,
269
+ * `next.previous` naming `previous`) that only adds. Every egress authority, pool, resolver and
270
+ * platform endpoint of `previous` is in `next` with exactly the same content, and the prefixes of
271
+ * `next` still cover every address `previous` protected, however split.
272
+ *
273
+ * Such a step is a cluster topology change: a node joins the egress, or a cluster's hub joins the
274
+ * platform endpoints. It changes no address, endpoint or pool anything already allocated or selected
275
+ * under `previous` relies on. It does not waive the step's barrier: every egress authority still
276
+ * acknowledges `next` with its own protection receipt before an allocation is bound to it
277
+ * (`bindRuntimeNetworkAllocationProtection`). Any removal or change, a later step than the next one,
278
+ * or another chain answers `false`. It validates both authorities, including their digests, and
279
+ * rejects either that does not validate.
280
+ */
281
+ export const continuesRuntimeNetworkProtection = async (previousValue: unknown, nextValue: unknown): Promise<boolean> => {
282
+ const previous = snapshotRuntimeNetworkProtectedAuthority(previousValue);
283
+ const next = snapshotRuntimeNetworkProtectedAuthority(nextValue);
284
+ requireValue(previous.digest === await computeRuntimeNetworkProtectedAuthorityDigest(previous)
285
+ && next.digest === await computeRuntimeNetworkProtectedAuthorityDigest(next));
286
+ if (equals(previous, next)) return true;
287
+ const ownerKey = (value: IRuntimeNetworkProtectedAuthority['egressAuthorities'][number]) =>
288
+ canonical([value.nodeId, value.runtimeNamespace]);
289
+ return next.id === previous.id && equals(next.controller, previous.controller)
290
+ && equals(next.previous, { id: previous.id, generation: previous.generation, digest: previous.digest })
291
+ && retains(previous.egressAuthorities, next.egressAuthorities, ownerKey)
292
+ && retains(previous.pools, next.pools, value => value.id)
293
+ && retains(previous.resolvers, next.resolvers, value => value.id)
294
+ && retains(previous.platformEndpoints, next.platformEndpoints, value => value.id)
295
+ && previous.prefixes.every(prefix => covered(prefix, next.prefixes));
296
+ };
297
+
252
298
  /** Detached, digest-verified allocation binding. Does not establish pool activation or live link identity. */
253
299
  export const bindRuntimeNetworkHandoffLease = async (leaseValue: unknown, authorityValue: unknown): Promise<{
254
300
  lease: IRuntimeNetworkHandoffLease; authority: IRuntimeNetworkProtectedAuthority;
@@ -1,6 +1,7 @@
1
1
  import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
2
2
  import { runtimeNetworkAddressPlanContract, type TRuntimeNetworkVpnTransport } from './networkaddressplan.js';
3
3
  import { snapshotRuntimeSessionBinding, type IRuntimeSessionBinding } from './runtimesession.js';
4
+ import { continuesRuntimeNetworkProtection } from './networkauthority.js';
4
5
  import { snapshotRuntimeNetworkProjection, validateRuntimeNetworkProjection,
5
6
  type IRuntimeNetworkProjection } from './networkprojection.js';
6
7
  import { snapshotRuntimeNetworkDnsLeaseRenewal, validateRuntimeNetworkDnsLeaseRenewal,
@@ -84,11 +85,11 @@ export const bindGetRuntimeManagedVpnCredentialRequest = async (value: unknown,
84
85
  * A valid projection's handoff lease names the projection's own node scope, so one handoff
85
86
  * reference is one node scope and the scope needs no comparison of its own.
86
87
  */
87
- const continuesMembership = (previous: IRuntimeNetworkProjection, next: IRuntimeNetworkProjection): boolean =>
88
+ const continuesMembership = async (previous: IRuntimeNetworkProjection, next: IRuntimeNetworkProjection): Promise<boolean> =>
88
89
  previous.handoff !== null && next.handoff !== null && previous.id === next.id
89
- && networkEqual(networkReferenceOf(previous.protectedAuthority), networkReferenceOf(next.protectedAuthority))
90
90
  && networkEqual(networkReferenceOf(previous.handoff), networkReferenceOf(next.handoff))
91
- && (next.generation > previous.generation || networkEqual(networkReferenceOf(previous), networkReferenceOf(next)));
91
+ && (next.generation > previous.generation || networkEqual(networkReferenceOf(previous), networkReferenceOf(next)))
92
+ && await continuesRuntimeNetworkProtection(previous.protectedAuthority, next.protectedAuthority);
92
93
 
93
94
  /** What a membership's credential keeps across its projections: everything but the projection and the expiry. */
94
95
  const credentialIdentity = (value: IGetRuntimeManagedVpnCredentialResponse) => ({
@@ -98,17 +99,21 @@ const credentialIdentity = (value: IGetRuntimeManagedVpnCredentialResponse) => (
98
99
 
99
100
  /**
100
101
  * Whether `next` continues the managed VPN membership `previous` belongs to: the same projection
101
- * lineage (`id`), the same protected authority, and the same handoff lease, which names the node
102
- * scope and carries the node's router incarnation. Both must hold a handoff, because a node without
103
- * one is no member. `next` is `previous` itself or a later generation; an earlier one, or another
104
- * projection under the same generation, continues nothing.
102
+ * lineage (`id`), a protected authority that continues the previous one
103
+ * (`continuesRuntimeNetworkProtection`: the same authority, or its next step that only adds), and
104
+ * the same handoff lease, which names the node scope and carries the node's router incarnation.
105
+ * Both must hold a handoff, because a node without one is no member. `next` is `previous` itself
106
+ * or a later generation; an earlier one, or another projection under the same generation,
107
+ * continues nothing.
105
108
  *
106
109
  * Everything else a projection states may move under one membership: its workload endpoints, their
107
110
  * readiness, observations and egress, its private networks, its router selection and its DNS window.
108
- * Those are what the member's network carries, never who the member is. A protection bump, a fresh
109
- * handoff (router reincarnation) or a new controller epoch ends the membership. Whether the hub a
110
- * credential names is still selected is the credential's question
111
- * (`bindGetRuntimeManagedVpnCredentialSuccessor`), not the membership's.
111
+ * Those are what the member's network carries, never who the member is. So does a cluster topology
112
+ * change: a node joining the egress or a cluster hub joining the platform endpoints is an additive
113
+ * protection step, and every member keeps its key pair across it. A protection step that removes or
114
+ * changes anything, or skips a step, a fresh handoff (router reincarnation) or a new controller
115
+ * epoch ends the membership. Whether the hub a credential names is still selected is the
116
+ * credential's question (`bindGetRuntimeManagedVpnCredentialSuccessor`), not the membership's.
112
117
  *
113
118
  * Validates both complete projections and rejects either that does not validate; a valid pair that
114
119
  * is not one membership answers `false`. It samples no clock and fences no session.
@@ -190,7 +195,7 @@ export const bindGetRuntimeManagedVpnCredentialSuccessor = async (value: unknown
190
195
  const previousProjection = await validateRuntimeNetworkProjection(previousInput);
191
196
  const projection = await validateRuntimeNetworkProjection(projectionInput);
192
197
  const renewal = renewalInput === null ? null : await validateRuntimeNetworkDnsLeaseRenewal(renewalInput);
193
- requireNetwork(continuesMembership(previousProjection, projection));
198
+ requireNetwork(await continuesMembership(previousProjection, projection));
194
199
  requireNetwork(networkEqual(previous.session, trusted)
195
200
  && networkEqual(previous.projection, networkReferenceOf(previousProjection)));
196
201
  requireNetwork(networkEqual(credentialIdentity(previous), credentialIdentity(response)));
@@ -0,0 +1,231 @@
1
+ import type { TSha256Digest } from './immutableimage.js';
2
+ import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
3
+ import type { IRuntimeNetworkNodeScope } from './networkprojection.js';
4
+ import type { IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
5
+ import { computeRuntimeNetworkProtectedAuthorityDigest, runtimeNetworkAuthorityContract,
6
+ snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
7
+ import { nodeRuntimeRetirementDispositions, type TNodeRuntimeRetirementDisposition } from './noderetirement.js';
8
+ import { readDataElements } from '../private/dataproperties.js';
9
+ import { isOneOf } from '../private/producercontract.js';
10
+ import { captureNetwork, failNetwork, networkCanonical, networkCounter, networkCoveredBy, networkDigest, networkEqual,
11
+ networkHash, networkId, networkList, networkNodeId, networkRecord, networkReference, networkReferenceOf, networkSafe,
12
+ networkScope, networkSorted, requireNetwork } from '../private/networkcontract.js';
13
+
14
+ /** One egress owner of a protected authority, as `IRuntimeNetworkProtectedAuthority.egressAuthorities` names it. */
15
+ export interface IRuntimeNetworkEgressOwner {
16
+ nodeId: string;
17
+ runtimeNamespace: string;
18
+ }
19
+
20
+ /** A remaining owner's acknowledged projection head, which carries no endpoint of the retired owner. */
21
+ export interface IRuntimeNetworkEgressRetirementClearance extends IRuntimeNetworkEgressOwner {
22
+ projection: IRuntimeAuthorityReference;
23
+ }
24
+
25
+ /**
26
+ * The independent fence that lets one egress owner leave a protected authority.
27
+ *
28
+ * A protected authority's egress owners only grow by default: an owner that is offline, revoked or
29
+ * gone can never acknowledge a successor, and omitting it is not a revocation. This record is the
30
+ * controller's statement that the owner holds no packet authority any more, recorded against the
31
+ * exact authority it is about to leave. Every member is evidence the controller read from its own
32
+ * durable state when it recorded the fence:
33
+ *
34
+ * 1. the node carries an `INodeRuntimeRetirement` row (`disposition` copies it);
35
+ * 2. both node credentials are revoked (`revokedCredentials`, committed at `revokedAt`), so the node
36
+ * can no longer register, renew a DNS lease or fetch a managed VPN credential;
37
+ * 3. every DNS and managed VPN window the node was ever issued has ended (`packetWindowExpiresAt`),
38
+ * so no hub relays for it even if it was never told to stop;
39
+ * 4. every handoff and workload lease the node was granted is quarantined (`quarantinedHandoffs`,
40
+ * `quarantinedWorkloads`) and is never handed out again;
41
+ * 5. every remaining owner of `protectedAuthority` acknowledged a projection head that carries no
42
+ * endpoint of the node (`clearedProjections`). An owner whose own `lost` fence is recorded
43
+ * against the same authority is not a remaining owner here: it never acknowledges anything again,
44
+ * so it can never clear another owner's endpoints, and it leaves behind its own fence.
45
+ *
46
+ * `lastReceipt` is the node's last accepted protection receipt. A `withdrawn` node took part in its
47
+ * retirement, so it always has one — its null withdrawal; a `lost` node may never have reported.
48
+ *
49
+ * A structural snapshot and digest do not authenticate any of it: the controller must fence the
50
+ * rows it read together with the authority it stages.
51
+ */
52
+ export interface IRuntimeNetworkEgressRetirement extends IRuntimeNetworkNodeScope {
53
+ /** The exact protected authority the owner leaves: the predecessor of the step that drops it. */
54
+ protectedAuthority: IRuntimeAuthorityReference;
55
+ disposition: TNodeRuntimeRetirementDisposition;
56
+ lastReceipt: IRuntimeAuthorityReference | null;
57
+ /** The generation each node credential was revoked at. */
58
+ revokedCredentials: { spark: number; pallet: number };
59
+ /** Unix milliseconds on the controller's clock when the later of the two revocations committed. */
60
+ revokedAt: number;
61
+ /**
62
+ * Unix milliseconds: the latest end of any DNS or managed VPN window the node was ever issued, or
63
+ * `revokedAt` when it was issued none.
64
+ */
65
+ packetWindowExpiresAt: number;
66
+ /** Unix milliseconds when the fence was recorded: never before `revokedAt` or `packetWindowExpiresAt`. */
67
+ fencedAt: number;
68
+ /** Every handoff lease the node was granted, each quarantined, in canonical order. */
69
+ quarantinedHandoffs: IRuntimeAuthorityReference[];
70
+ /** Every workload lease the node was granted, each quarantined, in canonical order. */
71
+ quarantinedWorkloads: IRuntimeAuthorityReference[];
72
+ /**
73
+ * One entry per remaining owner of `protectedAuthority` whose own `lost` fence is not recorded
74
+ * against that authority, in canonical owner order.
75
+ */
76
+ clearedProjections: IRuntimeNetworkEgressRetirementClearance[];
77
+ digest: TSha256Digest;
78
+ }
79
+
80
+ export const runtimeNetworkEgressRetirementContract = Object.freeze({
81
+ digestDomain: 'serve.zone/runtime-network-egress-retirement',
82
+ maximumQuarantinedHandoffs: runtimeNetworkAuthorityContract.maximumRetainedHandoffLeases,
83
+ maximumQuarantinedWorkloads: 512,
84
+ maximumClearedProjections: runtimeNetworkAuthorityContract.maximumEgressAuthorities - 1,
85
+ /** Fences one step may consume. Each is read on its own, so one step stays bounded. */
86
+ maximumFencesPerStep: 16,
87
+ /**
88
+ * Lost fences one step may hold as exemptions: owners it keeps that no consumed fence's clearance
89
+ * has to name. At least one owner is kept, so this is one fewer than the owners an authority has.
90
+ */
91
+ maximumExemptionsPerStep: runtimeNetworkAuthorityContract.maximumEgressAuthorities - 1,
92
+ /**
93
+ * Retired owners one step may be given: the caller's history restricted to owners `previous` or
94
+ * `next` names, which is at most every owner of both.
95
+ */
96
+ maximumRetiredOwners: 2 * runtimeNetworkAuthorityContract.maximumEgressAuthorities,
97
+ removal: 'absent-owner-requires-fence-bound-to-exact-previous-authority',
98
+ reentry: 'a-fenced-owner-never-returns',
99
+ } as const);
100
+
101
+ const ownerKey = (value: IRuntimeNetworkEgressOwner): string => networkCanonical([value.nodeId, value.runtimeNamespace]);
102
+ const referenceKey = (value: IRuntimeAuthorityReference): string => networkCanonical(value);
103
+
104
+ const readOwner = (value: unknown): IRuntimeNetworkEgressOwner => {
105
+ const item = networkRecord(value, ['nodeId', 'runtimeNamespace']);
106
+ return { nodeId: networkNodeId(item.nodeId), runtimeNamespace: networkId(item.runtimeNamespace) };
107
+ };
108
+ const readReferences = (value: unknown, maximum: number): IRuntimeAuthorityReference[] =>
109
+ networkSorted(networkList(value, maximum, networkReference), referenceKey);
110
+ const readClearance = (value: unknown): IRuntimeNetworkEgressRetirementClearance => {
111
+ const item = networkRecord(value, ['nodeId', 'runtimeNamespace', 'projection']);
112
+ return { ...readOwner({ nodeId: item.nodeId, runtimeNamespace: item.runtimeNamespace }),
113
+ projection: networkReference(item.projection) };
114
+ };
115
+
116
+ /** Exact-key structural snapshot, detached from its input. It verifies no digest and fences nothing. */
117
+ export const snapshotRuntimeNetworkEgressRetirement = (value: unknown): IRuntimeNetworkEgressRetirement => networkSafe(() => {
118
+ const item = networkRecord(captureNetwork(value), ['controller', 'nodeId', 'runtimeNamespace', 'protectedAuthority',
119
+ 'disposition', 'lastReceipt', 'revokedCredentials', 'revokedAt', 'packetWindowExpiresAt', 'fencedAt',
120
+ 'quarantinedHandoffs', 'quarantinedWorkloads', 'clearedProjections', 'digest']);
121
+ const scope = networkScope(item);
122
+ const disposition = item.disposition;
123
+ requireNetwork(isOneOf(nodeRuntimeRetirementDispositions, disposition));
124
+ const lastReceipt = item.lastReceipt === null ? null : networkReference(item.lastReceipt);
125
+ requireNetwork(disposition === 'lost' || lastReceipt !== null);
126
+ const credentials = networkRecord(item.revokedCredentials, ['spark', 'pallet']);
127
+ const revokedAt = networkCounter(item.revokedAt), packetWindowExpiresAt = networkCounter(item.packetWindowExpiresAt);
128
+ const fencedAt = networkCounter(item.fencedAt);
129
+ requireNetwork(fencedAt >= revokedAt && fencedAt >= packetWindowExpiresAt);
130
+ const contract = runtimeNetworkEgressRetirementContract;
131
+ const clearedProjections = networkSorted(networkList(item.clearedProjections, contract.maximumClearedProjections,
132
+ readClearance), ownerKey);
133
+ requireNetwork(clearedProjections.every(owner => ownerKey(owner) !== ownerKey(scope)));
134
+ return { ...scope, protectedAuthority: networkReference(item.protectedAuthority), disposition, lastReceipt,
135
+ revokedCredentials: { spark: networkCounter(credentials.spark), pallet: networkCounter(credentials.pallet) },
136
+ revokedAt, packetWindowExpiresAt, fencedAt,
137
+ quarantinedHandoffs: readReferences(item.quarantinedHandoffs, contract.maximumQuarantinedHandoffs),
138
+ quarantinedWorkloads: readReferences(item.quarantinedWorkloads, contract.maximumQuarantinedWorkloads),
139
+ clearedProjections, digest: networkDigest(item.digest) };
140
+ });
141
+
142
+ export const computeRuntimeNetworkEgressRetirementDigest = (value: IRuntimeNetworkEgressRetirement): Promise<TSha256Digest> =>
143
+ networkHash(runtimeNetworkEgressRetirementContract.digestDomain, snapshotRuntimeNetworkEgressRetirement(value));
144
+
145
+ /**
146
+ * Whether `next` may succeed `previous`, judged on its egress owners and the address space it keeps.
147
+ *
148
+ * Pure and fail-closed. `fences` are the egress retirements this step consumes. `exemptions` are
149
+ * `lost` fences of owners this step keeps, recorded against the same `previous`: such an owner never
150
+ * acknowledges another projection head, so no consumed fence's clearance can wait for it. They are
151
+ * read, not consumed, and default to none. `retiredOwners` is the caller's durable history of
152
+ * fenced owners restricted to the owners `previous` or `next` names, in canonical owner order. The
153
+ * step is accepted only when:
154
+ *
155
+ * - `next` is the exact successor of `previous` under the same controller, and both digests verify;
156
+ * - an owner of `previous` is absent from `next` only when exactly one fence, recorded against the
157
+ * exact `previous` reference and verified by its digest, names it — a fence for an owner `next`
158
+ * still names, or for an owner `previous` does not name, refuses the step;
159
+ * - every exemption verifies by its digest, is `lost`, is recorded under the same controller against
160
+ * the exact `previous` reference, and names an owner both `previous` and `next` name, no fence
161
+ * names and no other exemption names; at most `maximumExemptionsPerStep`, in canonical owner order;
162
+ * - every fence's `clearedProjections` names exactly the owners of `previous` this step keeps, less
163
+ * the exempt owners; an exemption's own clearance is not judged here, only when its owner leaves;
164
+ * - `retiredOwners` is empty: any entry is either an owner a side names that was fenced before, or
165
+ * not part of the restriction at all, so a fenced owner never returns;
166
+ * - every pool of `previous` is kept unchanged and the prefixes of `next` cover those of `previous`;
167
+ * a step that removes an owner also keeps every resolver of `previous` unchanged.
168
+ *
169
+ * Owners that join in the same step are admitted. With no fence this is the additive owner rule:
170
+ * nobody leaves, and exemptions relax nothing. It verifies no fence evidence against live state and
171
+ * fences no record: the caller fences every fence and exemption it read together with the authority
172
+ * it stages.
173
+ */
174
+ export const bindRuntimeNetworkProtectedAuthorityRetirement = async (
175
+ previousValue: unknown, nextValue: unknown, fencesValue: unknown, retiredOwnersValue: unknown,
176
+ exemptionsValue: unknown = [],
177
+ ): Promise<{
178
+ previous: IRuntimeNetworkProtectedAuthority;
179
+ next: IRuntimeNetworkProtectedAuthority;
180
+ fences: IRuntimeNetworkEgressRetirement[];
181
+ exemptions: IRuntimeNetworkEgressRetirement[];
182
+ }> => {
183
+ try {
184
+ const contract = runtimeNetworkEgressRetirementContract;
185
+ const previous = snapshotRuntimeNetworkProtectedAuthority(captureNetwork(previousValue));
186
+ const next = snapshotRuntimeNetworkProtectedAuthority(captureNetwork(nextValue));
187
+ const elements = readDataElements(fencesValue, contract.maximumFencesPerStep);
188
+ requireNetwork(elements);
189
+ const fences = networkSorted(elements.map(snapshotRuntimeNetworkEgressRetirement), ownerKey);
190
+ const exemptionElements = readDataElements(exemptionsValue, contract.maximumExemptionsPerStep);
191
+ requireNetwork(exemptionElements);
192
+ const exemptions = networkSorted(exemptionElements.map(snapshotRuntimeNetworkEgressRetirement), ownerKey);
193
+ const retiredOwners = networkSorted(networkList(captureNetwork(retiredOwnersValue), contract.maximumRetiredOwners,
194
+ readOwner), ownerKey);
195
+ requireNetwork(previous.digest === await computeRuntimeNetworkProtectedAuthorityDigest(previous)
196
+ && next.digest === await computeRuntimeNetworkProtectedAuthorityDigest(next));
197
+ const reference = networkReferenceOf(previous);
198
+ requireNetwork(networkEqual(next.controller, previous.controller) && next.id === previous.id
199
+ && next.generation === previous.generation + 1 && networkEqual(next.previous, reference));
200
+
201
+ const previousOwners = new Set(previous.egressAuthorities.map(ownerKey));
202
+ const nextOwners = new Set(next.egressAuthorities.map(ownerKey));
203
+ const fenced = new Set(fences.map(ownerKey));
204
+ for (const fence of fences) {
205
+ requireNetwork(fence.digest === await computeRuntimeNetworkEgressRetirementDigest(fence)
206
+ && networkEqual(fence.controller, previous.controller) && networkEqual(fence.protectedAuthority, reference)
207
+ && previousOwners.has(ownerKey(fence)) && !nextOwners.has(ownerKey(fence)));
208
+ }
209
+ requireNetwork([...previousOwners].every(owner => nextOwners.has(owner) || fenced.has(owner)));
210
+ for (const exemption of exemptions) {
211
+ requireNetwork(exemption.digest === await computeRuntimeNetworkEgressRetirementDigest(exemption)
212
+ && exemption.disposition === 'lost' && networkEqual(exemption.controller, previous.controller)
213
+ && networkEqual(exemption.protectedAuthority, reference) && previousOwners.has(ownerKey(exemption))
214
+ && nextOwners.has(ownerKey(exemption)));
215
+ }
216
+ // A fence's owner is never named by `next`, so no exemption names an owner a fence names.
217
+ const exempt = new Set(exemptions.map(ownerKey));
218
+ const cleared = previous.egressAuthorities.map(ownerKey).filter(owner => !fenced.has(owner) && !exempt.has(owner));
219
+ for (const fence of fences) {
220
+ requireNetwork(networkEqual(fence.clearedProjections.map(ownerKey), cleared));
221
+ }
222
+ requireNetwork(retiredOwners.length === 0);
223
+
224
+ requireNetwork(previous.pools.every(pool => next.pools.some(candidate => networkEqual(candidate, pool)))
225
+ && previous.prefixes.every(prefix => networkCoveredBy(next.prefixes, prefix)));
226
+ if (fences.length > 0) {
227
+ requireNetwork(previous.resolvers.every(resolver => next.resolvers.some(candidate => networkEqual(candidate, resolver))));
228
+ }
229
+ return { previous, next, fences, exemptions };
230
+ } catch { return failNetwork(); }
231
+ };