@serve.zone/interfaces 32.40.1 → 32.42.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 (41) hide show
  1. package/changelog.md +64 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/clusterrelaycredential.d.ts +72 -0
  4. package/dist_ts/data/clusterrelaycredential.js +82 -1
  5. package/dist_ts/data/corestorecontroltoken.d.ts +90 -0
  6. package/dist_ts/data/corestorecontroltoken.js +106 -0
  7. package/dist_ts/data/index.d.ts +2 -0
  8. package/dist_ts/data/index.js +3 -1
  9. package/dist_ts/data/networkaddressplan.js +3 -12
  10. package/dist_ts/data/networkauthority.d.ts +16 -0
  11. package/dist_ts/data/networkauthority.js +45 -1
  12. package/dist_ts/data/networkcredential.d.ts +12 -8
  13. package/dist_ts/data/networkcredential.js +18 -13
  14. package/dist_ts/data/networkegressretirement.d.ts +105 -0
  15. package/dist_ts/data/networkegressretirement.js +113 -0
  16. package/dist_ts/data/noderetirement.d.ts +24 -4
  17. package/dist_ts/data/noderetirement.js +20 -3
  18. package/dist_ts/private/networkcontract.d.ts +2 -0
  19. package/dist_ts/private/networkcontract.js +10 -1
  20. package/dist_ts/requests/cluster.d.ts +34 -0
  21. package/dist_ts/requests/corestorecontrol.d.ts +33 -0
  22. package/dist_ts/requests/corestorecontrol.js +2 -0
  23. package/dist_ts/requests/index.d.ts +2 -1
  24. package/dist_ts/requests/index.js +3 -2
  25. package/dist_ts/requests/node.d.ts +9 -1
  26. package/package.json +3 -3
  27. package/readme.md +176 -23
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/data/clusterrelaycredential.ts +149 -0
  30. package/ts/data/corestorecontroltoken.ts +195 -0
  31. package/ts/data/index.ts +2 -0
  32. package/ts/data/networkaddressplan.ts +3 -13
  33. package/ts/data/networkauthority.ts +46 -0
  34. package/ts/data/networkcredential.ts +17 -12
  35. package/ts/data/networkegressretirement.ts +195 -0
  36. package/ts/data/noderetirement.ts +41 -5
  37. package/ts/private/networkcontract.ts +9 -0
  38. package/ts/requests/cluster.ts +44 -0
  39. package/ts/requests/corestorecontrol.ts +43 -0
  40. package/ts/requests/index.ts +2 -0
  41. package/ts/requests/node.ts +9 -1
package/readme.md CHANGED
@@ -395,6 +395,16 @@ the declaration is not a revocation acknowledgement. Prefix arrays are sorted
395
395
  lexically and nonoverlapping; pools and endpoints are sorted by ID; egress owners
396
396
  are ordered by canonical JSON of `[nodeId, runtimeNamespace]`.
397
397
 
398
+ `continuesRuntimeNetworkProtection(previous, next)` states which successor of a protected authority
399
+ only adds. It answers `true` when `next` is `previous` itself, or exactly the next step of its chain
400
+ (the same `id` and controller, `next.previous` naming `previous`) in which every egress owner, pool,
401
+ resolver and platform endpoint of `previous` is still present with the same content and the prefixes
402
+ still cover every address `previous` protected, however they are split. A cluster topology change is
403
+ such a step: a node joins the egress, or a cluster's hub joins the platform endpoints. The step keeps
404
+ its barrier: every egress owner still reports a receipt for `next` before an allocation binds to it.
405
+ Any removal or change, a jump over a step, another chain or another controller answers `false`. Both
406
+ authorities and their digests are validated, and either that does not validate is rejected.
407
+
398
408
  `IRuntimeNetworkProtectionReceipt` reports a worker's durable, joined native
399
409
  protection journal separately from projection admission. It binds a complete
400
410
  protected-authority reference, node/controller scope, authenticated reporter,
@@ -411,9 +421,11 @@ current persisted protection receipt and durable session for every declared
411
421
  egress owner, in canonical owner order. Missing, unavailable, stale-protection
412
422
  and superseded-session evidence reject. An offline owner cannot be omitted.
413
423
  The controller must fence all those records together with lease insertion, before
414
- reserving any handoff or workload address. No generic independent-fence flag is
415
- provided; such a mechanism needs its own verified owner. Protection receipts
416
- neither prove workload readiness nor release packet/address/name quarantine.
424
+ reserving any handoff or workload address. There is no generic independent-fence
425
+ flag, quorum or expiry: the one way an owner leaves is
426
+ [Egress Owner Retirement](#egress-owner-retirement).
427
+ Protection receipts neither prove workload readiness nor release
428
+ packet/address/name quarantine.
417
429
 
418
430
  `IRuntimeNetworkHandoffLease` binds an immutable node/router incarnation to its
419
431
  protected-authority reference, transit subnet and peers, protocol-qualified source
@@ -732,9 +744,12 @@ A credential names one projection, but its key pair belongs to the node's manage
732
744
  membership, which outlives it. Every workload add, removal, readiness change or policy change
733
745
  signs a new projection generation; none of them changes who the member is.
734
746
  `continuesRuntimeManagedVpnMembership(previous, next)` is that rule: `next` continues the
735
- membership when it is the same projection lineage (`id`) under the same protected authority and
736
- the same handoff lease (which names the node scope and carries the router incarnation), and is
737
- `previous` itself or a later generation. A protection bump, a fresh handoff (router
747
+ membership when it is the same projection lineage (`id`), under a protected authority that
748
+ continues the previous one (`continuesRuntimeNetworkProtection`: the same authority or its next
749
+ step that only adds), with the same handoff lease (which names the node scope and carries the
750
+ router incarnation), and is `previous` itself or a later generation. A node joining the egress or a
751
+ cluster hub joining the platform endpoints therefore keeps every member's key pair and hub session.
752
+ A protection step that removes or changes anything or skips a step, a fresh handoff (router
738
753
  reincarnation) or a new controller epoch is a new membership; an earlier generation or another
739
754
  projection of the same generation continues nothing. It validates both projections and rejects
740
755
  one that does not validate.
@@ -761,6 +776,59 @@ Noise implementation owns cryptographic validation and authentication. Credentia
761
776
  delivery does not prove a connected VPN, TUN ownership, packet enforcement or
762
777
  workload readiness, and does not replace explicit packet withdrawals.
763
778
 
779
+ ### Egress Owner Retirement
780
+
781
+ An owner that is offline, revoked or gone can never acknowledge a successor, so
782
+ while it stays declared no allocation is possible after the next protection change.
783
+ `IRuntimeNetworkEgressRetirement` is the independent fence that lets it leave. Its
784
+ owner is the controller, which records it from its own durable state against the
785
+ exact protected authority the owner is about to leave, once all five hold:
786
+
787
+ 1. the node carries an `INodeRuntimeRetirement` row, whose `disposition` the fence
788
+ copies;
789
+ 2. both node credentials are revoked (`revokedCredentials`, committed at
790
+ `revokedAt`), so the node can no longer register, renew a DNS lease or fetch a
791
+ managed VPN credential;
792
+ 3. every DNS and managed VPN window the node was ever issued has ended
793
+ (`packetWindowExpiresAt`, or `revokedAt` when it was issued none), so no hub
794
+ relays for it even if it was never told to stop, and `fencedAt` is not earlier;
795
+ 4. every handoff and workload lease the node was granted is quarantined
796
+ (`quarantinedHandoffs`, at most 256, and `quarantinedWorkloads`, at most 512,
797
+ both in canonical order) and is never handed out again;
798
+ 5. every remaining owner of that authority acknowledged a projection head that
799
+ carries no endpoint of the node (`clearedProjections`, one per remaining owner
800
+ in canonical owner order, never the node itself).
801
+
802
+ `lastReceipt` is the node's last accepted protection receipt. A `withdrawn` node
803
+ took part in its own retirement, so it always has one (its null withdrawal); a
804
+ `lost` node may have none. Neither disposition shortens the fence: a lost node
805
+ leaves behind exactly the same controller-owned evidence as a withdrawn one.
806
+ `snapshotRuntimeNetworkEgressRetirement` is the exact-key reader and
807
+ `computeRuntimeNetworkEgressRetirementDigest` seals it under
808
+ `serve.zone/runtime-network-egress-retirement`. Neither authenticates the
809
+ evidence; the controller fences the rows it read together with the authority it
810
+ stages.
811
+
812
+ `bindRuntimeNetworkProtectedAuthorityRetirement(previous, next, fences,
813
+ retiredOwners)` is the pure successor rule for the egress owners and the address
814
+ space of any step. `next` must be the exact successor of `previous` under the same
815
+ controller, with both digests verified. An owner of `previous` may be absent from
816
+ `next` only when exactly one fence recorded against the exact `previous` reference,
817
+ verified by its digest, names it; a fence for an owner `next` still names, or one
818
+ `previous` never named, refuses the step, and every fence's `clearedProjections`
819
+ must name exactly the owners the step keeps. `retiredOwners` is the complete,
820
+ canonically ordered list of owners earlier steps fenced, which the caller's
821
+ durable history supplies; neither side may name any of them, so a fenced owner
822
+ never returns. Every pool of `previous` stays unchanged and the prefixes of `next`
823
+ cover those of `previous`; a step that removes an owner also keeps every resolver.
824
+ Owners may join in the same step, and a step without fences is the additive rule.
825
+ One step consumes at most 16 fences (`runtimeNetworkEgressRetirementContract`).
826
+
827
+ A retirement step is a new protected authority: every remaining owner reports a
828
+ receipt citing it before anything new is allocated, while existing leases keep
829
+ their historical eligibility. It also ends every managed VPN membership, as every
830
+ protection change does, so each retirement costs the fleet one reconnect.
831
+
764
832
  ### DNS Lease Renewals
765
833
 
766
834
  A projection's `dns` window lasts at most fifteen minutes and is signed content, so a node's
@@ -1822,20 +1890,33 @@ status or assignments changed; it carries no state, so a late push never overwri
1822
1890
 
1823
1891
  ### Node Retirement
1824
1892
 
1825
- `data.INodeRuntimeRetirement { id, clusterId, state, actorId, since }` records that one node leaves
1826
- its cluster's Pallet runtime. `TNodeRuntimeRetirementState` is `retiring` while the producer still
1827
- stops and removes the node's slots, the compiler quarantines its handoff and both node credentials
1828
- are revoked, and `retired` once that has settled. It is stored beside the node document, because a
1829
- node update merges caller data and no caller may retire a node with it. The producers read the row's
1830
- presence rather than its state: a node that has one is no longer an egress member and desires no
1831
- slots, so the row is written before anything is taken away and stays as long as the node document.
1832
- `snapshotNodeRuntimeRetirement` checks the record.
1833
-
1834
- `requests.node.retireClusterNode { identity, nodeId, expectedGeneration }` answers `{ retirement }`.
1835
- `expectedGeneration` is the cluster runtime generation the caller read with `getClusterRuntime`, so a
1836
- cutover that moved the phase meanwhile refuses the write. Every refusal is one of
1837
- `data.nodeRuntimeRetirementRefusals` — `node-missing`, `cluster-runtime-not-pallet`,
1838
- `node-already-retired` or `conflict` — and takes nothing away.
1893
+ `data.INodeRuntimeRetirement { id, clusterId, state, disposition, actorId, since }` records that one
1894
+ node leaves its cluster's Pallet runtime. `TNodeRuntimeRetirementState` is `retiring` while the
1895
+ producer still stops and removes the node's slots, the compiler quarantines its handoff and leases,
1896
+ both node credentials are revoked and the node's egress fence is not yet recorded, and `retired`
1897
+ once the protected authority no longer names the node (see
1898
+ [Egress Owner Retirement](#egress-owner-retirement)). It is stored beside the node document, because
1899
+ a node update merges caller data and no caller may retire a node with it. The producers read the
1900
+ row's presence rather than its state: a node that has one is no longer an egress member and desires
1901
+ no slots, so the row is written before anything is taken away and stays as long as the node
1902
+ document. `snapshotNodeRuntimeRetirement` checks the record.
1903
+
1904
+ `TNodeRuntimeRetirementDisposition` (`nodeRuntimeRetirementDispositions`,
1905
+ `snapshotNodeRuntimeRetirementDisposition`) says how the node leaves. `withdrawn`: the node still
1906
+ runs and takes part — its slots are removed against its own removal receipts and its Pallet
1907
+ withdraws its protection receipt before its credentials are revoked. `lost`: an administrator
1908
+ states the node will never report again, which authorizes abandoning its slots without removal
1909
+ receipts and nothing else; its egress fence is the same as a withdrawn node's.
1910
+
1911
+ `requests.node.retireClusterNode { identity, nodeId, expectedGeneration, disposition? }` answers
1912
+ `{ retirement }`. `expectedGeneration` is the cluster runtime generation the caller read with
1913
+ `getClusterRuntime`, so a cutover that moved the phase meanwhile refuses the write. A request that
1914
+ states no disposition asks for `withdrawn`, the retirement this request always described; `lost`
1915
+ is taken only when stated, and asking again as `lost` escalates a `withdrawn` retirement that is
1916
+ still `retiring`. Every refusal is one of `data.nodeRuntimeRetirementRefusals` — `node-missing`,
1917
+ `cluster-runtime-not-pallet`, `node-already-retired`, `conflict` or `node-still-reporting` (a `lost`
1918
+ retirement of a node whose last accepted report is younger than
1919
+ `clusterNodeObservationFreshnessMs`) — and takes nothing away.
1839
1920
 
1840
1921
  ### Corestore Database Export Packaging
1841
1922
 
@@ -2723,8 +2804,9 @@ cluster authority every cluster-side component used before it. `IClusterRelayCre
2723
2804
  metadata: the cluster, a `generation` that advances by one per accepted mint or rotation, the
2724
2805
  machine `userId` it authenticates as, and `delivery`, the `ISecretVersionReference` of the
2725
2806
  immutable encrypted version the bearer was delivered as. No record, receipt, request or response
2726
- of this contract carries a bearer, a hash or key material, and every reader is exact-keyed, so a
2727
- peer that adds a secret-looking member is refused rather than copied.
2807
+ of this contract carries a bearer, a hash or key material, except the one answer that exists to
2808
+ hand the bearer over (see the export below), and every reader is exact-keyed, so a peer that adds
2809
+ a secret-looking member is refused rather than copied.
2728
2810
 
2729
2811
  The credential authenticates the cluster's whole control-plane session, exactly what the shared
2730
2812
  authority authenticated, so a relay changes one credential and keeps every call it makes. The gain
@@ -2776,7 +2858,36 @@ it names or that record's revoked successor — and to a registration whose `cre
2776
2858
  invalidation's `replacement`. It is a content check; dereferencing each record as current and
2777
2859
  requiring the live socket remain Cloudly's.
2778
2860
 
2779
- All three requests read only the verified `IIdentityCredential`, never an identity member a caller
2861
+ `exportClusterRelayAuthorization { identity, clusterId, expectedGeneration }` answers the bearer
2862
+ itself, so an operator hands it to the relay without an exec into Cloudly's process. Cloudly mints
2863
+ nothing to answer it: the bearer is the encrypted version the credential's `delivery` names, read
2864
+ at exactly `expectedGeneration` (never a minimum) and proved against the hash that generation
2865
+ issued, so a rotation committing meanwhile is refused rather than answered. A new bearer is a
2866
+ `rotate` followed by an export at the new generation. It requires a freshly authenticated
2867
+ administrator, exactly as a credential mutation does. `IClusterRelayAuthorizationExport` carries
2868
+ the sealed `credential` at that generation, the `authorization` (the bearer, byte for byte what the
2869
+ relay presents: `clusterRelayToken_<credential id>_<64 hex>`, no line terminator) and the
2870
+ `receipt`. It is the only answer of this contract that carries a bearer; Cloudly never logs it, and
2871
+ its reader writes it where the relay reads it and nowhere else.
2872
+
2873
+ Every answered export is audited: Cloudly writes `IClusterRelayAuthorizationExportReceipt`
2874
+ (`id`, `clusterId`, the exact `credential` reference, the administrator's `actorId`,
2875
+ `exportedAt`) before it answers, so no bearer leaves without a record, and a retried export is a
2876
+ second receipt rather than a replay. A receipt is value-free: no bearer, no hash.
2877
+ `getClusterRelayAuthorizationExports { identity, clusterId }` answers a cluster's receipts, oldest
2878
+ first. Refusals are `data.clusterRelayAuthorizationExportRefusals`
2879
+ (`data.TClusterRelayAuthorizationExportRefusal`): `relay-credential-absent`,
2880
+ `relay-credential-generation-mismatch`, `relay-authorization-missing` and
2881
+ `relay-authorization-ambiguous`. A reader checks an answer with
2882
+ `bindClusterRelayAuthorizationExport(answer, { clusterId, expectedGeneration })` before it writes a
2883
+ byte: the credential's digest holds, it is the requested cluster's at the requested generation, the
2884
+ bearer names that credential, and the receipt names that exact credential. Every refusal is the
2885
+ contract's one value-free message and never quotes the answer. `isClusterRelayAuthorization`
2886
+ states the bearer's shape, and `snapshotClusterRelayAuthorizationExportRequest`,
2887
+ `snapshotClusterRelayAuthorizationExportReceipt` and `snapshotClusterRelayAuthorizationExport` are
2888
+ the exact-keyed readers.
2889
+
2890
+ All five requests read only the verified `IIdentityCredential`, never an identity member a caller
2780
2891
  states about itself.
2781
2892
 
2782
2893
  ### Spark Legacy Runtime Retirement
@@ -3581,6 +3692,7 @@ Request groups are exported by product area:
3581
3692
  - `requests.coremail`
3582
3693
  - `requests.coremailAdmin`
3583
3694
  - `requests.corestore`
3695
+ - `requests.corestorecontrol`
3584
3696
  - `requests.deployment`
3585
3697
  - `requests.dns`
3586
3698
  - `requests.domain`
@@ -3694,6 +3806,47 @@ bytes and digests exactly.
3694
3806
  payload, one-byte-larger framed snapshot-original, 128 MiB verified closure
3695
3807
  plaintext, and 256 MiB closure boundaries used by Corestore.
3696
3808
 
3809
+ ### Corestore Control Token Administration
3810
+
3811
+ The elected relay of every cluster calls its nodes' Corestore control API with one control token
3812
+ (`CORESTORE_API_TOKEN`), which Cloudly stores for both Corestore provider configs and hands to the
3813
+ relay sealed (see Corestore Runtime Credentials). `requests.corestorecontrol` lets an administrator
3814
+ store that token without an exec into Cloudly's process.
3815
+
3816
+ `setCorestoreControlToken { identity, token, expected }` stores `token` under both provider configs
3817
+ in one transaction, so either both hold it or neither does. `token` must pass
3818
+ `isCorestoreControlToken` (32 to 4096 UTF-8 bytes, no whitespace) and is the only member of this
3819
+ contract that carries the token: it is never in an answer, a receipt, a log or a hook, and a sender
3820
+ never persists the request. `expected` is the `data.TCorestoreControlTokenState` the administrator
3821
+ last read: `{ state: 'absent' }` or `{ state: 'present', revision }`, never a minimum, so a set that
3822
+ committed in between is refused rather than overwritten. `revision` is 1 for the first token ever
3823
+ stored and advances by exactly one with every accepted set; no request removes a token. It requires
3824
+ a freshly authenticated administrator, exactly as a relay credential mutation does. The answer,
3825
+ `data.ICorestoreControlTokenSetResult`, is the state the set produced and its receipt. Refusals are
3826
+ `data.corestoreControlTokenSetRefusals` (`data.TCorestoreControlTokenSetRefusal`):
3827
+ `token-invalid`, `token-present`, `token-absent`, `token-revision-mismatch` and
3828
+ `provider-unavailable`.
3829
+
3830
+ Every accepted set is audited: Cloudly writes `data.ICorestoreControlTokenSetReceipt` (`id`,
3831
+ `revision`, `via`, `actorId`, `setAt`) in the transaction that stores the token. `via` is
3832
+ `admin-request` with the administrator's `actorId`, or `container-cli` with
3833
+ `data.corestoreControlTokenContainerCliActorId` (`operator:secrets-cli`) when Cloudly's in-container
3834
+ `secrets set-corestore-control-token` stored it; that command advances the same revision. A receipt
3835
+ is value-free: no token, no hash. `getCorestoreControlTokenState { identity }` answers
3836
+ `data.ICorestoreControlTokenStateRead`: the state and its receipts, oldest first, contiguous and
3837
+ ending at the state's revision; no token stored means no receipt. Only a token stored before
3838
+ receipts existed, which Cloudly's migration records as revision 1, has no receipt, so the receipts
3839
+ start at revision 1 or 2. A read answers at most `data.corestoreControlTokenMaximumReceipts` (4096)
3840
+ receipts: past that many sets it answers the latest 4096, whose oldest may name any revision.
3841
+
3842
+ A reader checks a set answer with `bindCorestoreControlTokenSet(answer, request)`: the state is
3843
+ present at revision 1 after an absent expectation and at the expected revision plus one otherwise,
3844
+ and the receipt is an `admin-request` receipt for exactly that revision.
3845
+ `snapshotCorestoreControlTokenSetRequest`, `snapshotCorestoreControlTokenSetResult`,
3846
+ `snapshotCorestoreControlTokenSetReceipt`, `snapshotCorestoreControlTokenState` and
3847
+ `snapshotCorestoreControlTokenStateRead` are the exact-keyed readers. Every refusal is the
3848
+ contract's one value-free message and never quotes the token.
3849
+
3697
3850
  ### Corestore Through The Relay
3698
3851
 
3699
3852
  A serve.zone cluster is outbound-only and Corestore is node-local, so Cloudly never reaches a
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.40.1',
6
+ version: '32.42.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -128,6 +128,53 @@ export interface IClusterLegacyRelayCredentialRetirementResult {
128
128
  replayed: boolean;
129
129
  }
130
130
 
131
+ /**
132
+ * What an administrator names to export one cluster's relay bearer: the cluster, at exactly the
133
+ * generation `getClusterRelayCredential` answered. Never a minimum: a rotation that committed
134
+ * after that read is refused rather than answered with a bearer the operator did not ask for.
135
+ */
136
+ export interface IClusterRelayAuthorizationExportRequest {
137
+ clusterId: string;
138
+ expectedGeneration: number;
139
+ }
140
+
141
+ /**
142
+ * The audit record of one answered export, which Cloudly writes before it answers the bearer.
143
+ *
144
+ * Append-only and value-free: it names who received which credential's bearer and when, never
145
+ * the bearer or a hash of it. Every answered export writes one, so a retried export is a second
146
+ * receipt rather than a replay: each is a separate handover of the same bearer.
147
+ */
148
+ export interface IClusterRelayAuthorizationExportReceipt {
149
+ id: string;
150
+ clusterId: string;
151
+ /** The exact relay credential whose bearer was answered: identity, generation and digest. */
152
+ credential: IRuntimeAuthorityReference;
153
+ /** The administrator the bearer was answered to, read from the verified credential. */
154
+ actorId: string;
155
+ exportedAt: number;
156
+ }
157
+
158
+ /**
159
+ * One cluster's relay bearer, answered to an administrator so it can be handed to the relay
160
+ * without reaching into Cloudly's process.
161
+ *
162
+ * The only member of this contract that carries a bearer. It is the delivered version the
163
+ * credential names, at the requested generation, proved against the hash that generation issued;
164
+ * Cloudly mints nothing to answer it. The reader owns the bytes: it writes them where the relay
165
+ * reads them and never logs, prints or persists them anywhere else.
166
+ */
167
+ export interface IClusterRelayAuthorizationExport {
168
+ /** The relay credential the bearer authenticates as, at exactly the requested generation. */
169
+ credential: IClusterRelayCredential;
170
+ /**
171
+ * The bearer, byte for byte what the relay presents: `clusterRelayToken_`, the credential's
172
+ * 64-hex `id`, `_` and 64 hex characters, with no line terminator.
173
+ */
174
+ authorization: string;
175
+ receipt: IClusterRelayAuthorizationExportReceipt;
176
+ }
177
+
131
178
  export const clusterRelayCredentialContract = Object.freeze({
132
179
  credentialDigestDomain: 'serve.zone/cluster-relay-credential',
133
180
  legacyCredentialDigestDomain: 'serve.zone/cluster-relay-legacy-credential',
@@ -154,6 +201,24 @@ export const clusterRelayCredentialRefusals = Object.freeze([
154
201
 
155
202
  export type TClusterRelayCredentialRefusal = typeof clusterRelayCredentialRefusals[number];
156
203
 
204
+ /**
205
+ * Every reason Cloudly answers instead of a relay bearer. Its own family, because an export
206
+ * changes no record: it has no `conflict`, and a generation it was not asked for is a mismatch.
207
+ */
208
+ export const clusterRelayAuthorizationExportRefusals = Object.freeze([
209
+ /** No relay credential is minted for the cluster. */
210
+ 'relay-credential-absent',
211
+ /** The credential is at another generation than the one named, before the read or after it. */
212
+ 'relay-credential-generation-mismatch',
213
+ /** The version the credential delivered is not stored as an active version. */
214
+ 'relay-authorization-missing',
215
+ /** The secret's active version, or its bytes, are not what the credential issued. */
216
+ 'relay-authorization-ambiguous',
217
+ ] as const);
218
+
219
+ export type TClusterRelayAuthorizationExportRefusal =
220
+ typeof clusterRelayAuthorizationExportRefusals[number];
221
+
157
222
  const fail = (): never => { throw new Error('Cluster relay credential contract rejected.'); };
158
223
  const safe = <T>(operationArg: () => T): T => {
159
224
  try { return operationArg(); } catch { return fail(); }
@@ -359,3 +424,87 @@ export const bindClusterLegacyRelayCredentialInvalidation = async (
359
424
  return { invalidation, legacyCredential, acceptedRegistration };
360
425
  } catch { return fail(); }
361
426
  };
427
+
428
+ const clusterRelayAuthorizationPrefix = 'clusterRelayToken_';
429
+ const clusterRelayAuthorizationPattern = /^clusterRelayToken_[a-f0-9]{64}_[a-f0-9]{64}$/;
430
+
431
+ /**
432
+ * Whether a value has the exact shape of a relay bearer: `clusterRelayToken_<64 hex>_<64 hex>`.
433
+ *
434
+ * Shape only: whether it authenticates is Cloudly's to decide against the hash it keeps.
435
+ */
436
+ export const isClusterRelayAuthorization = (valueArg: unknown): valueArg is string =>
437
+ typeof valueArg === 'string' && clusterRelayAuthorizationPattern.test(valueArg);
438
+
439
+ /** The credential identity a relay bearer names: the 64 hex characters after its prefix. */
440
+ const clusterRelayAuthorizationCredentialId = (authorizationArg: string): string =>
441
+ authorizationArg.slice(
442
+ clusterRelayAuthorizationPrefix.length,
443
+ clusterRelayAuthorizationPrefix.length + 64,
444
+ );
445
+
446
+ export const snapshotClusterRelayAuthorizationExportRequest = (
447
+ valueArg: unknown,
448
+ ): IClusterRelayAuthorizationExportRequest => safe(() => {
449
+ const value = networkRecord(captureNetwork(valueArg), ['clusterId', 'expectedGeneration']);
450
+ return {
451
+ clusterId: networkId(value.clusterId),
452
+ expectedGeneration: networkCounter(value.expectedGeneration),
453
+ };
454
+ });
455
+
456
+ export const snapshotClusterRelayAuthorizationExportReceipt = (
457
+ valueArg: unknown,
458
+ ): IClusterRelayAuthorizationExportReceipt => safe(() => {
459
+ const value = networkRecord(captureNetwork(valueArg), [
460
+ 'id', 'clusterId', 'credential', 'actorId', 'exportedAt',
461
+ ]);
462
+ return {
463
+ id: networkId(value.id),
464
+ clusterId: networkId(value.clusterId),
465
+ credential: networkReference(value.credential),
466
+ actorId: networkId(value.actorId),
467
+ exportedAt: networkCounter(value.exportedAt),
468
+ };
469
+ });
470
+
471
+ /**
472
+ * Structural copy of an export answer. A refusal never quotes the answer, because it carries a
473
+ * bearer: every failure is the contract's one value-free message.
474
+ */
475
+ export const snapshotClusterRelayAuthorizationExport = (
476
+ valueArg: unknown,
477
+ ): IClusterRelayAuthorizationExport => safe(() => {
478
+ const value = networkRecord(captureNetwork(valueArg), ['credential', 'authorization', 'receipt']);
479
+ requireNetwork(isClusterRelayAuthorization(value.authorization));
480
+ return {
481
+ credential: snapshotClusterRelayCredential(value.credential),
482
+ authorization: value.authorization,
483
+ receipt: snapshotClusterRelayAuthorizationExportReceipt(value.receipt),
484
+ };
485
+ });
486
+
487
+ /**
488
+ * Joins an export answer to the request it answers, before a reader writes a byte of the bearer.
489
+ *
490
+ * The credential is sealed with its own digest and is the requested cluster's at exactly the
491
+ * requested generation; the bearer names that credential; the receipt names that cluster and that
492
+ * exact credential. Content join only: whether the bearer authenticates is Cloudly's to prove,
493
+ * and Cloudly proves it before it answers.
494
+ */
495
+ export const bindClusterRelayAuthorizationExport = async (
496
+ exportArg: unknown,
497
+ requestArg: unknown,
498
+ ): Promise<IClusterRelayAuthorizationExport> => {
499
+ try {
500
+ const request = snapshotClusterRelayAuthorizationExportRequest(requestArg);
501
+ const answer = snapshotClusterRelayAuthorizationExport(exportArg);
502
+ if ((await validateClusterRelayCredential(answer.credential)).length) fail();
503
+ requireNetwork(answer.credential.clusterId === request.clusterId
504
+ && answer.credential.generation === request.expectedGeneration
505
+ && clusterRelayAuthorizationCredentialId(answer.authorization) === answer.credential.id
506
+ && answer.receipt.clusterId === request.clusterId
507
+ && networkEqual(answer.receipt.credential, networkReferenceOf(answer.credential)));
508
+ return answer;
509
+ } catch { return fail(); }
510
+ };
@@ -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';