@serve.zone/interfaces 32.16.0 → 32.17.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.
@@ -2,11 +2,23 @@ import * as plugins from '../plugins.js';
2
2
 
3
3
  import { type IDockerRegistryInfo } from '../data/docker.js';
4
4
  import type { IClusterNode } from './clusternode.js';
5
+ import type { IFleetCutoverAcceptedEvidenceReference } from './fleetcutover.js';
6
+ import type { TSha256Digest } from './immutableimage.js';
7
+ import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
5
8
  import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
6
9
  import { validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
7
10
  import { hasExactKeys } from '../private/dataproperties.js';
8
11
  import { isClusterNodeId } from '../private/nodeidentifier.js';
9
- import { networkNoiseKey } from '../private/networkcontract.js';
12
+ import {
13
+ captureNetwork,
14
+ networkCounter,
15
+ networkDigest,
16
+ networkHash,
17
+ networkId,
18
+ networkNoiseKey,
19
+ networkRecord,
20
+ networkReference,
21
+ } from '../private/networkcontract.js';
10
22
 
11
23
  export interface ICluster {
12
24
  id: string;
@@ -137,6 +149,16 @@ export interface IRegisterClusterRelayResponse {
137
149
  origin: string;
138
150
  /** Cloudly's own offer, so an accepted relay judges the session it just registered into. */
139
151
  protocol: IProtocolOffer;
152
+ /**
153
+ * The relay credential generation Cloudly authenticated this socket as.
154
+ *
155
+ * Read from Cloudly's own record and never from the registration: a relay that stated its own
156
+ * generation would be asserting its own rank. It is how a relay learns the exact generation its
157
+ * readiness is bound to.
158
+ */
159
+ credential: IRuntimeAuthorityReference;
160
+ /** The accepted registration record this answer wrote, as a retirement record later cites it. */
161
+ acceptedRegistration: IFleetCutoverAcceptedEvidenceReference;
140
162
  }
141
163
 
142
164
  /**
@@ -155,6 +177,13 @@ export const clusterRelayUndispatchedRefusals = Object.freeze([
155
177
  'relay-node-foreign',
156
178
  /** A registration of this relay identity at the same or a higher sequence is already recorded. */
157
179
  'relay-sequence-not-advancing',
180
+ /**
181
+ * The credential this socket authenticated with is no longer the cluster's current generation.
182
+ *
183
+ * Rotation has no overlap, so another relay may already hold the current one: this relay is not
184
+ * the one Cloudly dispatches to, which `relay-identity-unverified` never states.
185
+ */
186
+ 'relay-credential-generation-stale',
158
187
  ] as const);
159
188
 
160
189
  /** One of the reasons Cloudly states that it does not dispatch to a relay. */
@@ -342,14 +371,12 @@ export const validateRegisterClusterRelayRequest = (
342
371
  };
343
372
 
344
373
  /**
345
- * One cluster's relay as Cloudly serves it to an administrator.
374
+ * What one accepted relay registration states about the relay that sent it.
346
375
  *
347
- * This is the read shape, not the registration a relay sends: `IRegisterClusterRelayRequest` is
348
- * what the relay puts on the wire, and this is what Cloudly answers with after it accepted one.
349
- * `clusterId` names the cluster the relay serves, and `acceptedAt` is Cloudly's own acceptance
350
- * time, which is what orders two registrations of one cluster.
376
+ * The part the served read shape and the accepted record share: no record identity, no digest and
377
+ * no liveness, so neither shape restates the other's fields.
351
378
  */
352
- export interface IClusterRelayRegistration {
379
+ export interface IClusterRelayRegistrationFacts {
353
380
  clusterId: string;
354
381
  nodeId: string;
355
382
  listenAddress: string;
@@ -357,6 +384,17 @@ export interface IClusterRelayRegistration {
357
384
  version: string;
358
385
  registrationSequence: number;
359
386
  acceptedAt: number;
387
+ }
388
+
389
+ /**
390
+ * One cluster's relay as Cloudly serves it to an administrator.
391
+ *
392
+ * This is the read shape, not the registration a relay sends: `IRegisterClusterRelayRequest` is
393
+ * what the relay puts on the wire, and this is what Cloudly answers with after it accepted one.
394
+ * `clusterId` names the cluster the relay serves, and `acceptedAt` is Cloudly's own acceptance
395
+ * time, which is what orders two registrations of one cluster.
396
+ */
397
+ export interface IClusterRelayRegistration extends IClusterRelayRegistrationFacts {
360
398
  /**
361
399
  * Whether a verified socket carries this relay at this instant.
362
400
  *
@@ -367,6 +405,26 @@ export interface IClusterRelayRegistration {
367
405
  live: boolean;
368
406
  }
369
407
 
408
+ const clusterRelayRegistrationFactKeys = [
409
+ 'clusterId', 'nodeId', 'listenAddress', 'listenPort', 'version', 'registrationSequence',
410
+ 'acceptedAt',
411
+ ] as const;
412
+
413
+ /** The one format rule of every registration fact, whichever shape carries it. */
414
+ const validateClusterRelayRegistrationFacts = (
415
+ valueArg: Record<string, unknown>,
416
+ pathArg: string,
417
+ ): string[] => [
418
+ ...validateClusterRelayIdentifier(valueArg.clusterId, `${pathArg} clusterId`),
419
+ ...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
420
+ ...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
421
+ ...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
422
+ ...validateClusterRelayVersion(valueArg.version, `${pathArg} version`),
423
+ ...validateClusterRelayNonNegativeInteger(valueArg.registrationSequence,
424
+ `${pathArg} registrationSequence`),
425
+ ...validateClusterRelayNonNegativeInteger(valueArg.acceptedAt, `${pathArg} acceptedAt`),
426
+ ];
427
+
370
428
  /**
371
429
  * Validates one served relay registration and names every reason it is refused.
372
430
  *
@@ -378,23 +436,95 @@ export const validateClusterRelayRegistration = (
378
436
  valueArg: unknown,
379
437
  pathArg = 'cluster relay registration',
380
438
  ): string[] => {
381
- if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['clusterId', 'nodeId', 'listenAddress',
382
- 'listenPort', 'version', 'registrationSequence', 'acceptedAt', 'live'])) {
439
+ if (!isRecord(valueArg)
440
+ || !hasExactKeys(valueArg, [...clusterRelayRegistrationFactKeys, 'live'])) {
383
441
  return [`${pathArg} must use its exact schema`];
384
442
  }
385
443
  return [
386
- ...validateClusterRelayIdentifier(valueArg.clusterId, `${pathArg} clusterId`),
387
- ...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
388
- ...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
389
- ...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
390
- ...validateClusterRelayVersion(valueArg.version, `${pathArg} version`),
391
- ...validateClusterRelayNonNegativeInteger(valueArg.registrationSequence,
392
- `${pathArg} registrationSequence`),
393
- ...validateClusterRelayNonNegativeInteger(valueArg.acceptedAt, `${pathArg} acceptedAt`),
444
+ ...validateClusterRelayRegistrationFacts(valueArg, pathArg),
394
445
  ...(typeof valueArg.live === 'boolean' ? [] : [`${pathArg} live must be a boolean`]),
395
446
  ];
396
447
  };
397
448
 
449
+ /**
450
+ * Cloudly's accepted registration record: the thing an evidence reference names.
451
+ *
452
+ * `credential` is the exact relay credential generation the registering socket authenticated as,
453
+ * so a reader can tell a registration made under the current credential from one made under a
454
+ * superseded one. Liveness is deliberately absent: a live socket is not a fact a record can hold,
455
+ * and a digest that covered it would change without anything having been registered.
456
+ */
457
+ export interface IClusterRelayAcceptedRegistration extends IClusterRelayRegistrationFacts {
458
+ id: string;
459
+ /** Advances by one per accepted registration, so a reference to a superseded one fails closed. */
460
+ generation: number;
461
+ credential: IRuntimeAuthorityReference;
462
+ digest: TSha256Digest;
463
+ }
464
+
465
+ export const clusterRelayContract = Object.freeze({
466
+ acceptedRegistrationDigestDomain: 'serve.zone/cluster-relay-accepted-registration',
467
+ } as const);
468
+
469
+ const failClusterRelayRecord = (): never => {
470
+ throw new Error('Cluster relay contract rejected.');
471
+ };
472
+
473
+ /** The facts of an already exact-keyed record, refused unless each passes its one format rule. */
474
+ const readClusterRelayRegistrationFacts = (
475
+ valueArg: Record<string, unknown>,
476
+ ): IClusterRelayRegistrationFacts => {
477
+ if (validateClusterRelayRegistrationFacts(valueArg, 'cluster relay registration').length > 0) {
478
+ failClusterRelayRecord();
479
+ }
480
+ const {
481
+ clusterId, nodeId, listenAddress, listenPort, version, registrationSequence, acceptedAt,
482
+ } = valueArg;
483
+ // Each member passed its format rule above, which is what the assertion states.
484
+ return {
485
+ clusterId, nodeId, listenAddress, listenPort, version, registrationSequence, acceptedAt,
486
+ } as IClusterRelayRegistrationFacts;
487
+ };
488
+
489
+ /** Structural copy only; whether the record is the cluster's current one is Cloudly's to decide. */
490
+ export const snapshotClusterRelayAcceptedRegistration = (
491
+ valueArg: unknown,
492
+ ): IClusterRelayAcceptedRegistration => {
493
+ try {
494
+ const value = networkRecord(captureNetwork(valueArg), [
495
+ ...clusterRelayRegistrationFactKeys, 'id', 'generation', 'credential', 'digest',
496
+ ]);
497
+ return {
498
+ ...readClusterRelayRegistrationFacts(value),
499
+ id: networkId(value.id),
500
+ generation: networkCounter(value.generation),
501
+ credential: networkReference(value.credential),
502
+ digest: networkDigest(value.digest),
503
+ };
504
+ } catch {
505
+ return failClusterRelayRecord();
506
+ }
507
+ };
508
+
509
+ export const computeClusterRelayAcceptedRegistrationDigest = async (
510
+ valueArg: unknown,
511
+ ): Promise<TSha256Digest> => networkHash(
512
+ clusterRelayContract.acceptedRegistrationDigestDomain,
513
+ snapshotClusterRelayAcceptedRegistration(valueArg),
514
+ );
515
+
516
+ export const validateClusterRelayAcceptedRegistration = async (
517
+ valueArg: unknown,
518
+ ): Promise<string[]> => {
519
+ try {
520
+ const value = snapshotClusterRelayAcceptedRegistration(valueArg);
521
+ return value.digest === await computeClusterRelayAcceptedRegistrationDigest(value)
522
+ ? [] : ['cluster relay accepted registration digest does not match'];
523
+ } catch {
524
+ return ['cluster relay accepted registration must use its exact canonical schema'];
525
+ }
526
+ };
527
+
398
528
  /**
399
529
  * The name the relay's certificate is issued for, and the A record Cloudly
400
530
  * owns for it. Derived from the origin so every consumer derives it
@@ -20,6 +20,10 @@ import {
20
20
  type IFleetCutoverPreparedVolumeHandoffIntent,
21
21
  type IFleetCutoverPreparedVolumeHandoffPage,
22
22
  } from './fleetcutover.js';
23
+ import {
24
+ validateSparkLegacyRuntimeRetirement,
25
+ type ISparkLegacyRuntimeRetirement,
26
+ } from './sparklegacyretirement.js';
23
27
  import { protocolRefusalHttpStatus, validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
24
28
  import {
25
29
  canonicalizeStrictJson,
@@ -242,6 +246,8 @@ interface ISparkSwarmObservationBase {
242
246
  observedAt: number;
243
247
  /** Available in every observation state; exact page membership proves an empty node subset. */
244
248
  fleetCutoverHeldVolumeFences?: IFleetCutoverHeldVolumeFenceEvidencePage;
249
+ /** Available in every observation state too: this describes the host, not its Docker daemon. */
250
+ fleetCutoverLegacyRetirement?: ISparkLegacyRuntimeRetirement;
245
251
  }
246
252
 
247
253
  export type TSparkSwarmObservation =
@@ -628,6 +634,8 @@ export const validateSparkSwarmObservation = async (
628
634
  'state',
629
635
  ...(observation.fleetCutoverHeldVolumeFences === undefined
630
636
  ? [] : ['fleetCutoverHeldVolumeFences']),
637
+ ...(observation.fleetCutoverLegacyRetirement === undefined
638
+ ? [] : ['fleetCutoverLegacyRetirement']),
631
639
  ];
632
640
  const expectedKeys = observation.state === 'unknown'
633
641
  ? [...commonKeys, 'reason']
@@ -676,6 +684,11 @@ export const validateSparkSwarmObservation = async (
676
684
  errorArg => `Spark Swarm observation ${errorArg}`,
677
685
  ));
678
686
  }
687
+ if (observation.fleetCutoverLegacyRetirement !== undefined) {
688
+ errors.push(...validateSparkLegacyRuntimeRetirement(
689
+ observation.fleetCutoverLegacyRetirement,
690
+ ).map(errorArg => `Spark Swarm observation ${errorArg}`));
691
+ }
679
692
  if (observation.state === 'member') {
680
693
  if (!isRuntimeIdentifier(observation.localSwarmNodeId)
681
694
  || typeof observation.controlAvailable !== 'boolean') {
@@ -0,0 +1,361 @@
1
+ import type { IClusterRelayAcceptedRegistration } from './cluster.js';
2
+ import {
3
+ snapshotClusterRelayAcceptedRegistration,
4
+ validateClusterRelayAcceptedRegistration,
5
+ } from './cluster.js';
6
+ import type { IFleetCutoverAcceptedEvidenceReference } from './fleetcutover.js';
7
+ import type { TSha256Digest } from './immutableimage.js';
8
+ import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
9
+ import type { ISecretVersionReference } from './secret.js';
10
+ import {
11
+ captureNetwork,
12
+ networkCounter,
13
+ networkDigest,
14
+ networkEqual,
15
+ networkHash,
16
+ networkId,
17
+ networkRecord,
18
+ networkReference,
19
+ networkReferenceOf,
20
+ requireNetwork,
21
+ } from '../private/networkcontract.js';
22
+
23
+ /**
24
+ * The relay authority Cloudly mints for one cluster. Public metadata only: never a bearer or hash.
25
+ *
26
+ * It authenticates the cluster's whole control-plane session, exactly what the shared cluster
27
+ * authority authenticated before it, so the relay changes one credential and keeps every call it
28
+ * makes. What it adds is a generation Cloudly fences each socket against and an authority it can
29
+ * rotate without touching the shared one; its scope is not narrower. One cluster has one relay and
30
+ * therefore one such record, and rotation is its whole lifecycle: it never expires by itself.
31
+ */
32
+ export interface IClusterRelayCredential {
33
+ id: string;
34
+ clusterId: string;
35
+ /** Monotonic state: one accepted mint or rotation advances it by exactly one. */
36
+ generation: number;
37
+ /** The machine identity this credential authenticates as; Cloudly's own, never caller-chosen. */
38
+ userId: string;
39
+ /** The immutable encrypted version the bearer was delivered as. */
40
+ delivery: ISecretVersionReference;
41
+ actorId: string;
42
+ acceptedAt: number;
43
+ digest: TSha256Digest;
44
+ }
45
+
46
+ interface IClusterRelayCredentialMutationBase {
47
+ clusterId: string;
48
+ /** Retain the complete request and this ID across retries; a changed body with it fails closed. */
49
+ mutationId: string;
50
+ }
51
+
52
+ /**
53
+ * Creation is absent-only; rotation requires the exact current generation, never a minimum.
54
+ *
55
+ * There is no revocation: a rotation already ends the previous generation at commit, so it covers a
56
+ * compromised credential without a state in which the cluster has no relay authority at all.
57
+ */
58
+ export type TClusterRelayCredentialMutation = IClusterRelayCredentialMutationBase & (
59
+ | { operation: 'ensure'; expectedGeneration: null }
60
+ | { operation: 'rotate'; expectedGeneration: number }
61
+ );
62
+
63
+ export interface IClusterRelayCredentialMutationResult {
64
+ credential: IClusterRelayCredential;
65
+ /** The shared pre-relay authority this cluster still carries; null when no record names one. */
66
+ legacy: IClusterLegacyRelayCredential | null;
67
+ /** A replay returns the original result without renewing delivery, generation or authority. */
68
+ replayed: boolean;
69
+ }
70
+
71
+ /**
72
+ * Cloudly's record of the shared cluster authority that predates the relay credential, so it can
73
+ * be named and retired. It is the machine identity every cluster-side component authenticated as;
74
+ * like the relay credential it carries no bearer and no hash.
75
+ */
76
+ export interface IClusterLegacyRelayCredential {
77
+ id: string;
78
+ clusterId: string;
79
+ userId: string;
80
+ generation: number;
81
+ state: 'active' | 'revoked';
82
+ /**
83
+ * The accepted invalidation that revoked it; null exactly while it is still accepted.
84
+ *
85
+ * The invalidation names this record at the generation before the revocation and this record
86
+ * names the invalidation at the generation after it, so neither digest covers the other's.
87
+ */
88
+ invalidation: IFleetCutoverAcceptedEvidenceReference | null;
89
+ recordedAt: number;
90
+ digest: TSha256Digest;
91
+ }
92
+
93
+ /**
94
+ * Caller intent only. Both references are compare-and-swap inputs: Cloudly dereferences them
95
+ * against its own current records and checks the relay's live socket itself.
96
+ */
97
+ export interface IClusterLegacyRelayCredentialRetirementRequest {
98
+ /** Retain the complete request and this ID across retries; a changed body with it fails closed. */
99
+ mutationId: string;
100
+ clusterId: string;
101
+ expectedLegacyGeneration: number;
102
+ /** The exact replacement relay credential generation the caller observed as current. */
103
+ replacement: IRuntimeAuthorityReference;
104
+ /** The exact accepted registration the caller observed as current. */
105
+ acceptedRegistration: IFleetCutoverAcceptedEvidenceReference;
106
+ }
107
+
108
+ /** The accepted receipt that one shared cluster authority stopped being accepted; written once. */
109
+ export interface IClusterLegacyRelayCredentialInvalidation {
110
+ id: string;
111
+ generation: 1;
112
+ clusterId: string;
113
+ /** The exact legacy generation this invalidated, read before the record advanced. */
114
+ credential: IRuntimeAuthorityReference;
115
+ /** The relay credential generation that was current when this was accepted. */
116
+ replacement: IRuntimeAuthorityReference;
117
+ /** The registration that was current, made under `replacement`, and live when this was accepted. */
118
+ acceptedRegistration: IFleetCutoverAcceptedEvidenceReference;
119
+ actorId: string;
120
+ acceptedAt: number;
121
+ digest: TSha256Digest;
122
+ }
123
+
124
+ export interface IClusterLegacyRelayCredentialRetirementResult {
125
+ credential: IClusterLegacyRelayCredential;
126
+ invalidation: IClusterLegacyRelayCredentialInvalidation;
127
+ /** A replay returns the recorded invalidation; nothing is revoked twice. */
128
+ replayed: boolean;
129
+ }
130
+
131
+ export const clusterRelayCredentialContract = Object.freeze({
132
+ credentialDigestDomain: 'serve.zone/cluster-relay-credential',
133
+ legacyCredentialDigestDomain: 'serve.zone/cluster-relay-legacy-credential',
134
+ legacyInvalidationDigestDomain: 'serve.zone/cluster-relay-legacy-credential-invalidation',
135
+ } as const);
136
+
137
+ /** Every reason Cloudly answers instead of mutating or retiring a cluster relay authority. */
138
+ export const clusterRelayCredentialRefusals = Object.freeze([
139
+ /** A rotation named a cluster that has no relay credential yet. */
140
+ 'relay-credential-absent',
141
+ /** A retirement named a cluster whose shared authority no record names. */
142
+ 'legacy-credential-absent',
143
+ /** The shared authority is already revoked; its invalidation is written once. */
144
+ 'legacy-credential-already-retired',
145
+ /** The named replacement is not the cluster's relay credential at its current generation. */
146
+ 'legacy-retirement-replacement-not-current',
147
+ /** The named registration is not current, or it was made under another credential generation. */
148
+ 'legacy-retirement-registration-not-current',
149
+ /** No verified socket carries the named registration at the instant of acceptance. */
150
+ 'legacy-retirement-relay-not-live',
151
+ /** An expected generation was not the exact current one, or a mutation ID met a changed body. */
152
+ 'conflict',
153
+ ] as const);
154
+
155
+ export type TClusterRelayCredentialRefusal = typeof clusterRelayCredentialRefusals[number];
156
+
157
+ const fail = (): never => { throw new Error('Cluster relay credential contract rejected.'); };
158
+ const safe = <T>(operationArg: () => T): T => {
159
+ try { return operationArg(); } catch { return fail(); }
160
+ };
161
+
162
+ const secretVersionReference = (valueArg: unknown): ISecretVersionReference => {
163
+ const value = networkRecord(valueArg, ['secretId', 'secretVersionId']);
164
+ return {
165
+ secretId: networkId(value.secretId),
166
+ secretVersionId: networkId(value.secretVersionId),
167
+ };
168
+ };
169
+
170
+ /** Structural copy only; whether the record is the cluster's current one is Cloudly's to decide. */
171
+ export const snapshotClusterRelayCredential = (
172
+ valueArg: unknown,
173
+ ): IClusterRelayCredential => safe(() => {
174
+ const value = networkRecord(captureNetwork(valueArg), [
175
+ 'id', 'clusterId', 'generation', 'userId', 'delivery', 'actorId', 'acceptedAt', 'digest',
176
+ ]);
177
+ return {
178
+ id: networkId(value.id),
179
+ clusterId: networkId(value.clusterId),
180
+ generation: networkCounter(value.generation),
181
+ userId: networkId(value.userId),
182
+ delivery: secretVersionReference(value.delivery),
183
+ actorId: networkId(value.actorId),
184
+ acceptedAt: networkCounter(value.acceptedAt),
185
+ digest: networkDigest(value.digest),
186
+ };
187
+ });
188
+
189
+ export const computeClusterRelayCredentialDigest = async (
190
+ valueArg: unknown,
191
+ ): Promise<TSha256Digest> => networkHash(
192
+ clusterRelayCredentialContract.credentialDigestDomain,
193
+ snapshotClusterRelayCredential(valueArg),
194
+ );
195
+
196
+ export const validateClusterRelayCredential = async (valueArg: unknown): Promise<string[]> => {
197
+ try {
198
+ const value = snapshotClusterRelayCredential(valueArg);
199
+ return value.digest === await computeClusterRelayCredentialDigest(value)
200
+ ? [] : ['cluster relay credential digest does not match'];
201
+ } catch { return ['cluster relay credential must use its exact canonical schema']; }
202
+ };
203
+
204
+ export const snapshotClusterRelayCredentialMutation = (
205
+ valueArg: unknown,
206
+ ): TClusterRelayCredentialMutation => safe(() => {
207
+ const value = networkRecord(captureNetwork(valueArg), [
208
+ 'clusterId', 'mutationId', 'operation', 'expectedGeneration',
209
+ ]);
210
+ const base = { clusterId: networkId(value.clusterId), mutationId: networkId(value.mutationId) };
211
+ const operation = value.operation;
212
+ requireNetwork(operation === 'ensure' || operation === 'rotate');
213
+ if (operation === 'ensure') {
214
+ requireNetwork(value.expectedGeneration === null);
215
+ return { ...base, operation, expectedGeneration: null };
216
+ }
217
+ return { ...base, operation, expectedGeneration: networkCounter(value.expectedGeneration) };
218
+ });
219
+
220
+ export const snapshotClusterLegacyRelayCredential = (
221
+ valueArg: unknown,
222
+ ): IClusterLegacyRelayCredential => safe(() => {
223
+ const value = networkRecord(captureNetwork(valueArg), [
224
+ 'id', 'clusterId', 'userId', 'generation', 'state', 'invalidation', 'recordedAt', 'digest',
225
+ ]);
226
+ const state = value.state;
227
+ requireNetwork(state === 'active' || state === 'revoked');
228
+ const generation = networkCounter(value.generation);
229
+ const invalidation = value.invalidation === null ? null : networkReference(value.invalidation);
230
+ // Revoked exactly when an invalidation names it, and only as the successor of an active record.
231
+ requireNetwork(state === 'active'
232
+ ? invalidation === null
233
+ : invalidation !== null && invalidation.generation === 1 && generation > 1);
234
+ return {
235
+ id: networkId(value.id),
236
+ clusterId: networkId(value.clusterId),
237
+ userId: networkId(value.userId),
238
+ generation,
239
+ state,
240
+ invalidation,
241
+ recordedAt: networkCounter(value.recordedAt),
242
+ digest: networkDigest(value.digest),
243
+ };
244
+ });
245
+
246
+ export const computeClusterLegacyRelayCredentialDigest = async (
247
+ valueArg: unknown,
248
+ ): Promise<TSha256Digest> => networkHash(
249
+ clusterRelayCredentialContract.legacyCredentialDigestDomain,
250
+ snapshotClusterLegacyRelayCredential(valueArg),
251
+ );
252
+
253
+ export const validateClusterLegacyRelayCredential = async (
254
+ valueArg: unknown,
255
+ ): Promise<string[]> => {
256
+ try {
257
+ const value = snapshotClusterLegacyRelayCredential(valueArg);
258
+ return value.digest === await computeClusterLegacyRelayCredentialDigest(value)
259
+ ? [] : ['cluster legacy relay credential digest does not match'];
260
+ } catch { return ['cluster legacy relay credential must use its exact canonical schema']; }
261
+ };
262
+
263
+ export const snapshotClusterLegacyRelayCredentialRetirementRequest = (
264
+ valueArg: unknown,
265
+ ): IClusterLegacyRelayCredentialRetirementRequest => safe(() => {
266
+ const value = networkRecord(captureNetwork(valueArg), [
267
+ 'mutationId', 'clusterId', 'expectedLegacyGeneration', 'replacement', 'acceptedRegistration',
268
+ ]);
269
+ return {
270
+ mutationId: networkId(value.mutationId),
271
+ clusterId: networkId(value.clusterId),
272
+ expectedLegacyGeneration: networkCounter(value.expectedLegacyGeneration),
273
+ replacement: networkReference(value.replacement),
274
+ acceptedRegistration: networkReference(value.acceptedRegistration),
275
+ };
276
+ });
277
+
278
+ export const snapshotClusterLegacyRelayCredentialInvalidation = (
279
+ valueArg: unknown,
280
+ ): IClusterLegacyRelayCredentialInvalidation => safe(() => {
281
+ const value = networkRecord(captureNetwork(valueArg), [
282
+ 'id', 'generation', 'clusterId', 'credential', 'replacement', 'acceptedRegistration',
283
+ 'actorId', 'acceptedAt', 'digest',
284
+ ]);
285
+ requireNetwork(value.generation === 1);
286
+ return {
287
+ id: networkId(value.id),
288
+ generation: 1,
289
+ clusterId: networkId(value.clusterId),
290
+ credential: networkReference(value.credential),
291
+ replacement: networkReference(value.replacement),
292
+ acceptedRegistration: networkReference(value.acceptedRegistration),
293
+ actorId: networkId(value.actorId),
294
+ acceptedAt: networkCounter(value.acceptedAt),
295
+ digest: networkDigest(value.digest),
296
+ };
297
+ });
298
+
299
+ export const computeClusterLegacyRelayCredentialInvalidationDigest = async (
300
+ valueArg: unknown,
301
+ ): Promise<TSha256Digest> => networkHash(
302
+ clusterRelayCredentialContract.legacyInvalidationDigestDomain,
303
+ snapshotClusterLegacyRelayCredentialInvalidation(valueArg),
304
+ );
305
+
306
+ export const validateClusterLegacyRelayCredentialInvalidation = async (
307
+ valueArg: unknown,
308
+ ): Promise<string[]> => {
309
+ try {
310
+ const value = snapshotClusterLegacyRelayCredentialInvalidation(valueArg);
311
+ return value.digest === await computeClusterLegacyRelayCredentialInvalidationDigest(value)
312
+ ? [] : ['cluster legacy relay credential invalidation digest does not match'];
313
+ } catch {
314
+ return ['cluster legacy relay credential invalidation must use its exact canonical schema'];
315
+ }
316
+ };
317
+
318
+ /**
319
+ * The active record is named exactly. The revoked one is that record's successor, so it is named
320
+ * by identity and the generation before it, and it names the invalidation back.
321
+ */
322
+ const invalidationNamesLegacyCredential = (
323
+ invalidationArg: IClusterLegacyRelayCredentialInvalidation,
324
+ legacyCredentialArg: IClusterLegacyRelayCredential,
325
+ ): boolean => legacyCredentialArg.state === 'active'
326
+ ? networkEqual(invalidationArg.credential, networkReferenceOf(legacyCredentialArg))
327
+ : invalidationArg.credential.id === legacyCredentialArg.id
328
+ && invalidationArg.credential.generation === legacyCredentialArg.generation - 1
329
+ && networkEqual(legacyCredentialArg.invalidation, networkReferenceOf(invalidationArg));
330
+
331
+ /**
332
+ * Joins an invalidation to the shared authority it revoked and to the registration that justified
333
+ * it, which must have been made under the very replacement the invalidation names.
334
+ *
335
+ * Content join only. Cloudly must still dereference each record as its cluster's current one and
336
+ * require a live verified socket for the registration while holding its compare-and-swap.
337
+ */
338
+ export const bindClusterLegacyRelayCredentialInvalidation = async (
339
+ invalidationArg: unknown,
340
+ legacyCredentialArg: unknown,
341
+ acceptedRegistrationArg: unknown,
342
+ ): Promise<{
343
+ invalidation: IClusterLegacyRelayCredentialInvalidation;
344
+ legacyCredential: IClusterLegacyRelayCredential;
345
+ acceptedRegistration: IClusterRelayAcceptedRegistration;
346
+ }> => {
347
+ try {
348
+ const invalidation = snapshotClusterLegacyRelayCredentialInvalidation(invalidationArg);
349
+ const legacyCredential = snapshotClusterLegacyRelayCredential(legacyCredentialArg);
350
+ const acceptedRegistration = snapshotClusterRelayAcceptedRegistration(acceptedRegistrationArg);
351
+ if ((await validateClusterLegacyRelayCredentialInvalidation(invalidation)).length
352
+ || (await validateClusterLegacyRelayCredential(legacyCredential)).length
353
+ || (await validateClusterRelayAcceptedRegistration(acceptedRegistration)).length) fail();
354
+ requireNetwork(legacyCredential.clusterId === invalidation.clusterId
355
+ && acceptedRegistration.clusterId === invalidation.clusterId
356
+ && invalidationNamesLegacyCredential(invalidation, legacyCredential)
357
+ && networkEqual(invalidation.acceptedRegistration, networkReferenceOf(acceptedRegistration))
358
+ && networkEqual(invalidation.replacement, acceptedRegistration.credential));
359
+ return { invalidation, legacyCredential, acceptedRegistration };
360
+ } catch { return fail(); }
361
+ };
package/ts/data/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './cluster.js';
3
3
  export * from './clusterruntime.js';
4
4
  export * from './clustervpn.js';
5
5
  export * from './clusteringress.js';
6
+ export * from './clusterrelaycredential.js';
6
7
  export * from './config.js';
7
8
  export * from './corestore.js';
8
9
  export * from './coremail.js';
@@ -52,6 +53,7 @@ export * from './baseos.js';
52
53
  export * from './backup.js';
53
54
  export * from './clusternode.js';
54
55
  export * from './settings.js';
56
+ export * from './sparklegacyretirement.js';
55
57
  export * from './sparknodereport.js';
56
58
  export * from './service.js';
57
59
  export * from './servicecredential.js';