@serve.zone/interfaces 32.27.0 → 32.29.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.
@@ -3,8 +3,10 @@ import type { IRuntimeAssignment, IRuntimeAuthorityReference, IRuntimeController
3
3
  import { snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
4
4
  import type { IRuntimeSessionBinding } from './runtimesession.js';
5
5
  import { snapshotRuntimeSessionBinding } from './runtimesession.js';
6
- import { captureNetwork, networkController, networkDigest, networkEqual, networkHash, networkId,
7
- networkNodeId, networkRecord, networkReference, networkReferenceOf, networkRevision, requireNetwork,
6
+ import type { IIdentityCredential } from './user.js';
7
+ import { captureNetwork, networkController, networkCounter, networkDigest, networkEqual, networkHash, networkId,
8
+ networkList, networkNodeId, networkRecord, networkReference, networkReferenceOf, networkRevision, networkSorted,
9
+ requireNetwork,
8
10
  } from '../private/networkcontract.js';
9
11
  import { parseCanonicalIpAddress } from '../private/ipaddress.js';
10
12
  import { isRuntimeIdentityNumber } from '../private/runtimepolicy.js';
@@ -161,6 +163,60 @@ export interface IApplyRuntimeLocalStorageClaimResponse {
161
163
  claim: IRuntimeAuthorityReference;
162
164
  }
163
165
 
166
+ /**
167
+ * What an administrator reads about one local storage claim a controller issued: where the volume
168
+ * is, whose it is, how it starts and what its node last answered.
169
+ *
170
+ * It is a projection of the controller's current generation, never the claim itself: it carries no
171
+ * digest, no controller epoch and no source beyond its kind, so an SMB claim's credential reference
172
+ * is not in it. `claimId` is the claim's `id`, the name the node keys the volume by, including the
173
+ * `import/<claimId>` directory an operator places an `import` volume's bytes in; a reader takes it
174
+ * from here and never derives it.
175
+ *
176
+ * `state` is what the node answered about exactly `generation`, or `null` while the node has not
177
+ * acknowledged that generation. It follows the rule `bindApplyRuntimeLocalStorageClaimResponse`
178
+ * holds an answer to: a bound claim is `ready`, `import-required` (an `import` claim only) or
179
+ * `uncertain`; a purge is `purged` or `uncertain`.
180
+ */
181
+ export interface IRuntimeLocalStorageClaimSummary {
182
+ claimId: string;
183
+ generation: number;
184
+ clusterId: string;
185
+ nodeId: string;
186
+ serviceId: string;
187
+ volumeId: string;
188
+ mountPath: string;
189
+ sourceKind: TRuntimeLocalStorageSource['kind'];
190
+ initialization: IRuntimeLocalStorageClaim['initialization'];
191
+ lifecycle: TRuntimeLocalStorageLifecycle;
192
+ state: TRuntimeLocalStorageClaimState | null;
193
+ }
194
+
195
+ /**
196
+ * An administrator's read of the local storage claims of one cluster, optionally of one node.
197
+ *
198
+ * Paged: `cursor` is the opaque `nextCursor` of the previous page, and `limit` asks for at most that
199
+ * many summaries, which the controller may lower but never exceed; both absent read the first page
200
+ * at the controller's own size.
201
+ */
202
+ export interface IGetRuntimeLocalStorageClaimsRequest {
203
+ /** The controller verifies this credential and derives every actor claim itself. */
204
+ identity: IIdentityCredential;
205
+ clusterId: string;
206
+ nodeId?: string;
207
+ cursor?: string;
208
+ limit?: number;
209
+ }
210
+
211
+ /**
212
+ * One page of summaries in strictly ascending `claimId` order. `nextCursor` is omitted on the final
213
+ * page; a page that carries it carries at least one summary.
214
+ */
215
+ export interface IGetRuntimeLocalStorageClaimsResponse {
216
+ claims: IRuntimeLocalStorageClaimSummary[];
217
+ nextCursor?: string;
218
+ }
219
+
164
220
  export const runtimeLocalStorageClaimContract = Object.freeze({
165
221
  digestDomain: 'serve.zone/runtime-local-storage-claim',
166
222
  error: 'Runtime local storage claim contract rejected.',
@@ -178,6 +234,11 @@ export const runtimeLocalStorageClaimContract = Object.freeze({
178
234
  maximumSegmentLength: 255,
179
235
  /** The longest SMB share name. */
180
236
  maximumShareLength: 80,
237
+ /**
238
+ * The most claim summaries one `getRuntimeLocalStorageClaims` page carries. A page of summaries
239
+ * with every field at its bound stays inside the network contract's one-mebibyte capture.
240
+ */
241
+ maximumSummaryPageSize: 200,
181
242
  } as const);
182
243
 
183
244
  const fail = (): never => { throw new Error(runtimeLocalStorageClaimContract.error); };
@@ -241,6 +302,14 @@ const shareOf = (valueArg: unknown): string => {
241
302
  return valueArg;
242
303
  };
243
304
 
305
+ /** The states a node may answer about one generation, whatever the answer's status. */
306
+ const isClaimStateOf = (
307
+ claimArg: Pick<IRuntimeLocalStorageClaim, 'lifecycle' | 'initialization'>, stateArg: TRuntimeLocalStorageClaimState,
308
+ ): boolean => claimArg.lifecycle === 'purge'
309
+ ? stateArg === 'purged' || stateArg === 'uncertain'
310
+ : stateArg === 'ready' || stateArg === 'uncertain'
311
+ || (stateArg === 'import-required' && claimArg.initialization === 'import');
312
+
244
313
  const literalOf = <TLiteral extends string>(valueArg: unknown, allowedArg: readonly TLiteral[]): TLiteral => {
245
314
  requireNetwork((allowedArg as readonly unknown[]).includes(valueArg));
246
315
  return valueArg as TLiteral;
@@ -424,11 +493,107 @@ export const bindApplyRuntimeLocalStorageClaimResponse = (
424
493
  const response = snapshotApplyRuntimeLocalStorageClaimResponse(valueArg);
425
494
  const { claim } = snapshotApplyRuntimeLocalStorageClaimRequest(requestArg);
426
495
  requireNetwork(networkEqual(response.claim, getRuntimeLocalStorageClaimReference(claim)));
427
- if (response.status !== 'historical') {
428
- requireNetwork(claim.lifecycle === 'purge'
429
- ? response.state === 'purged' || response.state === 'uncertain'
430
- : response.state === 'ready' || response.state === 'uncertain'
431
- || (response.state === 'import-required' && claim.initialization === 'import'));
432
- }
496
+ if (response.status !== 'historical') requireNetwork(isClaimStateOf(claim, response.state));
497
+ return response;
498
+ });
499
+
500
+ const sourceKinds = ['directory', 'nfs', 'smb'] as const;
501
+ const initializations = ['empty', 'import'] as const;
502
+ const lifecycles = ['bound', 'purge'] as const;
503
+
504
+ /** Detached exact-schema snapshot of one summary, its state judged against its lifecycle. */
505
+ export const snapshotRuntimeLocalStorageClaimSummary = (
506
+ valueArg: unknown,
507
+ ): IRuntimeLocalStorageClaimSummary => safe(() => {
508
+ const value = networkRecord(captureNetwork(valueArg), ['claimId', 'generation', 'clusterId', 'nodeId',
509
+ 'serviceId', 'volumeId', 'mountPath', 'sourceKind', 'initialization', 'lifecycle', 'state']);
510
+ const summary: IRuntimeLocalStorageClaimSummary = {
511
+ claimId: networkId(value.claimId),
512
+ generation: networkCounter(value.generation),
513
+ clusterId: networkId(value.clusterId),
514
+ nodeId: networkNodeId(value.nodeId),
515
+ serviceId: networkId(value.serviceId),
516
+ volumeId: networkId(value.volumeId),
517
+ mountPath: readMountPath(value.mountPath, 'mountPath'),
518
+ sourceKind: literalOf(value.sourceKind, sourceKinds),
519
+ initialization: literalOf(value.initialization, initializations),
520
+ lifecycle: literalOf(value.lifecycle, lifecycles),
521
+ state: value.state === null ? null : literalOf(value.state, claimStates),
522
+ };
523
+ // A purge always follows a bound generation, and an SMB volume always starts empty.
524
+ requireNetwork((summary.lifecycle === 'bound' || summary.generation > 1)
525
+ && (summary.sourceKind !== 'smb' || summary.initialization === 'empty')
526
+ && (summary.state === null || isClaimStateOf(summary, summary.state)));
527
+ return summary;
528
+ });
529
+
530
+ /**
531
+ * The one projection of a claim and its node's last answer into a summary. `stateArg` is what the
532
+ * node answered about exactly this generation, or `null` while it has not acknowledged it.
533
+ */
534
+ export const summarizeRuntimeLocalStorageClaim = (
535
+ claimArg: unknown, stateArg: TRuntimeLocalStorageClaimState | null,
536
+ ): IRuntimeLocalStorageClaimSummary => {
537
+ const claim = snapshotRuntimeLocalStorageClaim(claimArg);
538
+ return snapshotRuntimeLocalStorageClaimSummary({
539
+ claimId: claim.id,
540
+ generation: claim.generation,
541
+ clusterId: claim.clusterId,
542
+ nodeId: claim.nodeId,
543
+ serviceId: claim.serviceId,
544
+ volumeId: claim.volumeId,
545
+ mountPath: claim.mount.mountPath,
546
+ sourceKind: claim.source.kind,
547
+ initialization: claim.initialization,
548
+ lifecycle: claim.lifecycle,
549
+ state: stateArg,
550
+ });
551
+ };
552
+
553
+ /**
554
+ * Detached exact-schema snapshot of a read request. It checks the identity's shape only: the
555
+ * controller authenticates it.
556
+ */
557
+ export const snapshotGetRuntimeLocalStorageClaimsRequest = (
558
+ valueArg: unknown,
559
+ ): IGetRuntimeLocalStorageClaimsRequest => safe(() => {
560
+ const value = networkRecord(captureNetwork(valueArg), ['identity', 'clusterId'], ['nodeId', 'cursor', 'limit']);
561
+ const identity = networkRecord(value.identity, ['jwt']);
562
+ requireNetwork(typeof identity.jwt === 'string' && identity.jwt.length > 0);
563
+ return {
564
+ identity: { jwt: identity.jwt as string },
565
+ clusterId: networkId(value.clusterId),
566
+ ...(Object.hasOwn(value, 'nodeId') ? { nodeId: networkNodeId(value.nodeId) } : {}),
567
+ ...(Object.hasOwn(value, 'cursor') ? { cursor: networkId(value.cursor) } : {}),
568
+ ...(Object.hasOwn(value, 'limit')
569
+ ? { limit: networkCounter(value.limit, runtimeLocalStorageClaimContract.maximumSummaryPageSize) } : {}),
570
+ };
571
+ });
572
+
573
+ /** Detached exact-schema snapshot of one page: bounded, strictly ascending and never empty with a cursor. */
574
+ export const snapshotGetRuntimeLocalStorageClaimsResponse = (
575
+ valueArg: unknown,
576
+ ): IGetRuntimeLocalStorageClaimsResponse => safe(() => {
577
+ const value = networkRecord(captureNetwork(valueArg), ['claims'], ['nextCursor']);
578
+ const claims = networkList(value.claims, runtimeLocalStorageClaimContract.maximumSummaryPageSize,
579
+ snapshotRuntimeLocalStorageClaimSummary);
580
+ networkSorted(claims, (claimArg) => claimArg.claimId);
581
+ if (!Object.hasOwn(value, 'nextCursor')) return { claims };
582
+ requireNetwork(claims.length > 0);
583
+ return { claims, nextCursor: networkId(value.nextCursor) };
584
+ });
585
+
586
+ /**
587
+ * Matches a page to the read that asked for it: every summary names the requested cluster and, when
588
+ * the request named one, the requested node, and the page holds no more than the requested `limit`.
589
+ */
590
+ export const bindGetRuntimeLocalStorageClaimsResponse = (
591
+ valueArg: unknown, requestArg: unknown,
592
+ ): IGetRuntimeLocalStorageClaimsResponse => safe(() => {
593
+ const response = snapshotGetRuntimeLocalStorageClaimsResponse(valueArg);
594
+ const request = snapshotGetRuntimeLocalStorageClaimsRequest(requestArg);
595
+ requireNetwork(response.claims.length <= (request.limit ?? runtimeLocalStorageClaimContract.maximumSummaryPageSize)
596
+ && response.claims.every((claimArg) => claimArg.clusterId === request.clusterId
597
+ && (request.nodeId === undefined || claimArg.nodeId === request.nodeId)));
433
598
  return response;
434
599
  });
package/ts/data/secret.ts CHANGED
@@ -1893,6 +1893,22 @@ export const createAppStoreInstallSecretEnvelopeContext = (contextArg: {
1893
1893
  : null,
1894
1894
  });
1895
1895
 
1896
+ /**
1897
+ * The context a dcrouter admin bootstrap token is sealed under for `bootstrapExternalGatewayCredential`.
1898
+ *
1899
+ * It binds the envelope to the dcrouter URL and the gateway client id the administrator named, so a
1900
+ * sealed token opens only for a request that states the same target, and Cloudly hands it only to
1901
+ * a gateway its own settings name.
1902
+ */
1903
+ export const createGatewayBootstrapEnvelopeContext = (contextArg: {
1904
+ gatewayUrl: string;
1905
+ gatewayClientId: string;
1906
+ }): Uint8Array => contextBytes({
1907
+ purpose: 'serve.zone/gateway-bootstrap',
1908
+ gatewayUrl: contextArg.gatewayUrl,
1909
+ gatewayClientId: contextArg.gatewayClientId,
1910
+ });
1911
+
1896
1912
  export const createResolvedSecretMaterialEnvelopeContext = (contextArg: {
1897
1913
  recipientKeyId: string;
1898
1914
  organizationId: string;
@@ -1,5 +1,6 @@
1
1
  import type { TServiceMailSubmissionTlsMode } from './mail.js';
2
2
  import type { ISystemSecretOwner, TSecretManagementScope } from './secret.js';
3
+ import { hasExactKeys, readDataProperties } from '../private/dataproperties.js';
3
4
 
4
5
  export interface ICloudlyS3BackupTargetDescriptor {
5
6
  type: 's3';
@@ -109,3 +110,129 @@ export const cloudlySystemSecretKeys = Object.freeze({
109
110
  coreMailSmtpTlsCertificatePem: 'COREMAIL_SMTP_TLS_CERTIFICATE_PEM',
110
111
  coreMailSmtpTlsPrivateKeyPem: 'COREMAIL_SMTP_TLS_PRIVATE_KEY_PEM',
111
112
  } as const);
113
+
114
+ /**
115
+ * How far Cloudly's enrollment as a dcrouter gateway client has come.
116
+ *
117
+ * - `unconfigured` — no `dcrouterGatewayUrl` is set, so there is nothing to enroll with.
118
+ * - `checking` — Cloudly is validating or provisioning its credential right now.
119
+ * - `ready` — Cloudly holds a validated gateway-client credential whose policy matches its settings.
120
+ * - `degraded` — dcrouter could not be reached; Cloudly retries on its own.
121
+ * - `admin-bootstrap-required` — the held credential is missing, rejected or stale (its policy no
122
+ * longer matches Cloudly's settings or the runtime network address plan's DNS record allowance),
123
+ * and only a dcrouter admin token handed in with `bootstrapExternalGatewayCredential` moves it on.
124
+ */
125
+ export type TExternalGatewayEnrollmentStatus =
126
+ | 'unconfigured'
127
+ | 'checking'
128
+ | 'ready'
129
+ | 'degraded'
130
+ | 'admin-bootstrap-required';
131
+
132
+ /** Cloudly's gateway-client enrollment as an administrator reads it; it never carries a credential. */
133
+ export interface IExternalGatewayEnrollmentState {
134
+ status: TExternalGatewayEnrollmentStatus;
135
+ /** Why the state is what it is, in Cloudly's words; absent when there is nothing to say. */
136
+ reason?: string;
137
+ /** When Cloudly last finished a check, in milliseconds since the epoch. */
138
+ lastCheckedAt?: number;
139
+ /** When Cloudly retries on its own, for a `degraded` state. */
140
+ nextRetryAt?: number;
141
+ /** Whether Cloudly still holds a validated runtime credential it can serve gateway calls with. */
142
+ cachedValidationProof: boolean;
143
+ /** Whether a provisioned candidate credential still awaits finalization at dcrouter. */
144
+ finalizationPending: boolean;
145
+ }
146
+
147
+ export const externalGatewayEnrollmentStatuses = Object.freeze([
148
+ 'unconfigured',
149
+ 'checking',
150
+ 'ready',
151
+ 'degraded',
152
+ 'admin-bootstrap-required',
153
+ ] as const satisfies readonly TExternalGatewayEnrollmentStatus[]);
154
+
155
+ export const externalGatewayBootstrapContract = Object.freeze({
156
+ /**
157
+ * The longest admin bootstrap token Cloudly accepts, in bytes. A dcrouter API token is far
158
+ * shorter; the bound only keeps an unopened envelope from carrying an arbitrary payload.
159
+ */
160
+ maximumTokenBytes: 1_024,
161
+ /** The longest `reason` an enrollment state carries, in characters. */
162
+ maximumReasonLength: 4_096,
163
+ /** The longest `expectedGatewayUrl` a bootstrap request states, in characters. */
164
+ maximumGatewayUrlLength: 2_048,
165
+ /** The longest `expectedGatewayClientId` a bootstrap request states, in characters. */
166
+ maximumGatewayClientIdLength: 253,
167
+ } as const);
168
+
169
+ /** Every reason Cloudly answers instead of bootstrapping its gateway-client credential. */
170
+ export const externalGatewayBootstrapRefusals = Object.freeze([
171
+ /** Cloudly has no `dcrouterGatewayUrl` or no `dcrouterGatewayClientId` to enroll under. */
172
+ 'gateway-unconfigured',
173
+ /**
174
+ * The request's `expectedGatewayUrl` or `expectedGatewayClientId` is not what Cloudly's settings
175
+ * state, so the token would reach a dcrouter or a client the administrator did not name.
176
+ */
177
+ 'gateway-target-mismatch',
178
+ /** The sealed token does not open, or what it opens to breaks the bootstrap token rule. */
179
+ 'bootstrap-token-invalid',
180
+ /** dcrouter did not accept the token at all: unknown, revoked, disabled or expired. */
181
+ 'bootstrap-token-rejected',
182
+ /** dcrouter accepted the token, but its role is not `admin`, so it cannot provision a client. */
183
+ 'bootstrap-token-not-admin',
184
+ /** A candidate credential from an earlier provisioning is still being finalized; retry later. */
185
+ 'bootstrap-candidate-pending',
186
+ /** dcrouter could not be reached; the held credential is unchanged and the call may be repeated. */
187
+ 'gateway-unreachable',
188
+ /** dcrouter refused provisioning, or the credential it issued failed Cloudly's validation. */
189
+ 'bootstrap-provisioning-refused',
190
+ ] as const);
191
+
192
+ export type TExternalGatewayBootstrapRefusal = typeof externalGatewayBootstrapRefusals[number];
193
+
194
+ /**
195
+ * The bootstrap token rule, judged on the opened bytes: 1 to `maximumTokenBytes` bytes of visible
196
+ * ASCII, with no whitespace or control character. The token is dcrouter's; this only refuses what
197
+ * could never be one, so a pasted line break or an empty pipe never reaches dcrouter.
198
+ */
199
+ export const isExternalGatewayBootstrapToken = (valueArg: unknown): valueArg is Uint8Array => (
200
+ valueArg instanceof Uint8Array
201
+ && valueArg.length > 0
202
+ && valueArg.length <= externalGatewayBootstrapContract.maximumTokenBytes
203
+ && valueArg.every((byteArg) => byteArg >= 0x21 && byteArg <= 0x7e)
204
+ );
205
+
206
+ const isEpochMilliseconds = (valueArg: unknown): boolean => (
207
+ Number.isSafeInteger(valueArg) && (valueArg as number) >= 0
208
+ );
209
+
210
+ /** Structural check of an enrollment state as a client receives it; every problem is named. */
211
+ export const validateExternalGatewayEnrollmentState = (valueArg: unknown): string[] => {
212
+ const state = readDataProperties(valueArg);
213
+ if (state === null) return ['external gateway enrollment state must be a plain record'];
214
+ const errors: string[] = [];
215
+ if (!hasExactKeys(state, ['status', 'cachedValidationProof', 'finalizationPending'],
216
+ ['reason', 'lastCheckedAt', 'nextRetryAt'])) {
217
+ errors.push('external gateway enrollment state contains fields outside its schema');
218
+ }
219
+ if (!externalGatewayEnrollmentStatuses.some((statusArg) => statusArg === state.status)) {
220
+ errors.push('external gateway enrollment status is unknown');
221
+ }
222
+ if (state.reason !== undefined && (typeof state.reason !== 'string'
223
+ || state.reason.length === 0
224
+ || state.reason.length > externalGatewayBootstrapContract.maximumReasonLength)) {
225
+ errors.push('external gateway enrollment reason must be a bounded non-empty string');
226
+ }
227
+ for (const key of ['lastCheckedAt', 'nextRetryAt'] as const) {
228
+ if (state[key] !== undefined && !isEpochMilliseconds(state[key])) {
229
+ errors.push(`external gateway enrollment ${key} must be epoch milliseconds`);
230
+ }
231
+ }
232
+ for (const key of ['cachedValidationProof', 'finalizationPending'] as const) {
233
+ if (typeof state[key] !== 'boolean') {
234
+ errors.push(`external gateway enrollment ${key} must be a boolean`);
235
+ }
236
+ }
237
+ return errors;
238
+ };
@@ -9,7 +9,42 @@ export const failNetwork = (): never => { throw new Error('Runtime network proje
9
9
  export function requireNetwork(value: unknown): asserts value { if (!value) failNetwork(); }
10
10
  export const networkCanonical = (value: unknown): string => canonicalizeStrictJson(value, failNetwork);
11
11
  export const networkEqual = (left: unknown, right: unknown): boolean => networkCanonical(left) === networkCanonical(right);
12
- export const networkSafe = <T>(operation: () => T): T => { try { return operation(); } catch { return failNetwork(); } };
12
+
13
+ /** The runtime network address plan's lists that must be stated in their one canonical order. */
14
+ export type TRuntimeNetworkAddressPlanOrderedField = 'prefixes' | 'pools' | 'resolvers' | 'platformEndpoints';
15
+
16
+ /** The structure rules an address plan refusal names; `order` is the only one named so far. */
17
+ export type TRuntimeNetworkAddressPlanStructureRule = 'order';
18
+
19
+ /** Which list of a plan broke which structure rule; never a value from the plan. */
20
+ export interface IRuntimeNetworkAddressPlanStructureViolation {
21
+ field: TRuntimeNetworkAddressPlanOrderedField;
22
+ rule: TRuntimeNetworkAddressPlanStructureRule;
23
+ }
24
+
25
+ /**
26
+ * A plan refused for a structure rule the contract names, rather than with the contract's generic
27
+ * rejection. It is the one error `networkSafe` lets through, so a caller can tell an administrator
28
+ * which list to fix; its message names the field and the rule and quotes nothing from the plan.
29
+ */
30
+ export class RuntimeNetworkAddressPlanStructureError extends Error {
31
+ public readonly violation: IRuntimeNetworkAddressPlanStructureViolation;
32
+
33
+ public constructor(violationArg: IRuntimeNetworkAddressPlanStructureViolation) {
34
+ super(`Runtime network address plan rejected: ${violationArg.field} breaks the ${violationArg.rule} rule.`);
35
+ this.name = 'RuntimeNetworkAddressPlanStructureError';
36
+ this.violation = { field: violationArg.field, rule: violationArg.rule };
37
+ }
38
+ }
39
+
40
+ export const networkSafe = <T>(operation: () => T): T => {
41
+ try {
42
+ return operation();
43
+ } catch (error) {
44
+ if (error instanceof RuntimeNetworkAddressPlanStructureError) throw error;
45
+ return failNetwork();
46
+ }
47
+ };
13
48
 
14
49
  /** Bound decoded transport input before canonicalization; never execute getters/toJSON. */
15
50
  export const captureNetwork = (value: unknown): unknown => networkSafe(() => {
@@ -108,7 +108,8 @@ export interface IRequest_Any_Cloudly_GetNetworkNodes {
108
108
  * validates the plan with `snapshotRuntimeNetworkAddressPlan`, decides the change with
109
109
  * `admitRuntimeNetworkAddressPlanChange`, refuses the change while a resolver or platform endpoint
110
110
  * it removes or changes is still referenced, and answers with the stored plan: at its new revision,
111
- * or unchanged.
111
+ * or unchanged. A list stated out of its canonical order (see `IRuntimeNetworkAddressPlan`) is
112
+ * refused `plan-unsorted`, naming the list as the refusal's reference; the plan is never reordered.
112
113
  */
113
114
  export interface IReq_Any_Cloudly_SetRuntimeNetworkAddressPlan extends plugins.typedrequestInterfaces.implementsTR<
114
115
  plugins.typedrequestInterfaces.ITypedRequest, IReq_Any_Cloudly_SetRuntimeNetworkAddressPlan
@@ -179,6 +179,22 @@ export interface IReq_Controller_Pallet_ApplyRuntimeLocalStorageClaim extends pl
179
179
  response: localStorage.IApplyRuntimeLocalStorageClaimResponse;
180
180
  }
181
181
 
182
+ /**
183
+ * An administrator reads the local storage claims Cloudly issued in one cluster, optionally on one
184
+ * node, one page at a time: each claim's id, volume, mount path, initialization, lifecycle and what
185
+ * its node last answered. `snapshotGetRuntimeLocalStorageClaimsRequest` and
186
+ * `bindGetRuntimeLocalStorageClaimsResponse` judge both sides; a summary never carries the claim's
187
+ * source beyond its kind, so no credential reference leaves Cloudly through it.
188
+ */
189
+ export interface IReq_Any_Cloudly_GetRuntimeLocalStorageClaims extends plugins.typedrequestInterfaces.implementsTR<
190
+ plugins.typedrequestInterfaces.ITypedRequest,
191
+ IReq_Any_Cloudly_GetRuntimeLocalStorageClaims
192
+ > {
193
+ method: 'getRuntimeLocalStorageClaims';
194
+ request: localStorage.IGetRuntimeLocalStorageClaimsRequest;
195
+ response: localStorage.IGetRuntimeLocalStorageClaimsResponse;
196
+ }
197
+
182
198
  /** Current-peer report, including exact historical outbox recovery after reconnect. */
183
199
  export interface IReq_Pallet_Controller_ReportRuntimeAssignmentObservation extends plugins.typedrequestInterfaces.implementsTR<
184
200
  plugins.typedrequestInterfaces.ITypedRequest,
@@ -1,6 +1,10 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { ICloudlySettings } from '../data/settings.js';
3
- import type { IIdentity } from '../data/user.js';
2
+ import * as secretData from '../data/secret.js';
3
+ import * as settingsData from '../data/settings.js';
4
+ import type { IActiveSecretRecipientMetadata } from '../data/secret.js';
5
+ import type { ICloudlySettings, IExternalGatewayEnrollmentState } from '../data/settings.js';
6
+ import type { IIdentity, IIdentityCredential } from '../data/user.js';
7
+ import { readDataProperties, hasExactKeys } from '../private/dataproperties.js';
4
8
 
5
9
  // Get Settings
6
10
  export interface IRequest_GetSettings extends plugins.typedrequestInterfaces.ITypedRequest {
@@ -64,3 +68,159 @@ export interface IRequest_GetSetting extends plugins.typedrequestInterfaces.ITyp
64
68
  value: ICloudlySettings[keyof ICloudlySettings] | undefined;
65
69
  };
66
70
  }
71
+
72
+ /** A dcrouter admin bootstrap token, sealed to Cloudly's active secret ingress recipient. */
73
+ export interface IExternalGatewayBootstrapTokenInput {
74
+ mode: 'sealed';
75
+ /** Sealed under `data.createGatewayBootstrapEnvelopeContext` for the request's expected target. */
76
+ envelope: plugins.smartcrypto.IX25519EnvelopeV1;
77
+ }
78
+
79
+ /**
80
+ * Enrolls Cloudly as its dcrouter gateway client with a dcrouter admin token an administrator
81
+ * hands in once.
82
+ *
83
+ * Cloudly opens the token, checks the target the request names against its own settings, asks
84
+ * dcrouter whether the token is an admin token, and provisions, finalizes and stores its own
85
+ * least-privilege gateway-client credential with it — under the policy its settings and the runtime
86
+ * network address plan state now. The admin token is used for this one call and never stored; the
87
+ * administrator revokes it at dcrouter afterwards. A refusal leaves the credential Cloudly already
88
+ * holds unchanged and is answered with one of `data.externalGatewayBootstrapRefusals`.
89
+ *
90
+ * This is the one way a gateway credential enters Cloudly, for a first enrollment and for the
91
+ * re-bootstrap a changed address plan or gateway setting asks for (`admin-bootstrap-required`).
92
+ */
93
+ export interface IReq_Any_Cloudly_BootstrapExternalGatewayCredential
94
+ extends plugins.typedrequestInterfaces.implementsTR<
95
+ plugins.typedrequestInterfaces.ITypedRequest,
96
+ IReq_Any_Cloudly_BootstrapExternalGatewayCredential
97
+ > {
98
+ method: 'bootstrapExternalGatewayCredential';
99
+ request: {
100
+ identity: IIdentityCredential;
101
+ /** The `dcrouterGatewayUrl` the administrator read from Cloudly's settings. */
102
+ expectedGatewayUrl: string;
103
+ /** The `dcrouterGatewayClientId` the administrator read from Cloudly's settings. */
104
+ expectedGatewayClientId: string;
105
+ bootstrapToken: IExternalGatewayBootstrapTokenInput;
106
+ };
107
+ response: {
108
+ /** The enrollment after the bootstrap: `ready` when the new credential is Cloudly's runtime authority. */
109
+ state: IExternalGatewayEnrollmentState;
110
+ };
111
+ }
112
+
113
+ /** Cloudly's gateway-client enrollment state, for an administrator; it never carries a credential. */
114
+ export interface IReq_Any_Cloudly_GetExternalGatewayEnrollmentState
115
+ extends plugins.typedrequestInterfaces.implementsTR<
116
+ plugins.typedrequestInterfaces.ITypedRequest,
117
+ IReq_Any_Cloudly_GetExternalGatewayEnrollmentState
118
+ > {
119
+ method: 'getExternalGatewayEnrollmentState';
120
+ request: {
121
+ identity: IIdentityCredential;
122
+ };
123
+ response: {
124
+ state: IExternalGatewayEnrollmentState;
125
+ };
126
+ }
127
+
128
+ const hasJwtIdentity = (valueArg: unknown): boolean => {
129
+ const identity = readDataProperties(valueArg);
130
+ return identity !== null && typeof identity.jwt === 'string' && identity.jwt.length > 0;
131
+ };
132
+
133
+ /** Structural check of the target a bootstrap request names; empty when both are well formed. */
134
+ const validateExpectedGatewayTarget = (urlArg: unknown, clientIdArg: unknown): string[] => {
135
+ const errors: string[] = [];
136
+ let url: URL | undefined;
137
+ if (typeof urlArg === 'string'
138
+ && urlArg.length > 0
139
+ && urlArg.length <= settingsData.externalGatewayBootstrapContract.maximumGatewayUrlLength) {
140
+ try {
141
+ url = new URL(urlArg);
142
+ } catch {
143
+ url = undefined;
144
+ }
145
+ }
146
+ if (!url
147
+ || url.protocol !== 'https:'
148
+ || url.username !== ''
149
+ || url.password !== ''
150
+ || url.search !== ''
151
+ || url.hash !== '') {
152
+ errors.push('bootstrap expectedGatewayUrl must be an HTTPS URL without credentials, query or fragment');
153
+ }
154
+ if (typeof clientIdArg !== 'string'
155
+ || clientIdArg.length === 0
156
+ || clientIdArg.length > settingsData.externalGatewayBootstrapContract.maximumGatewayClientIdLength
157
+ || !/^[\x21-\x7e]+$/.test(clientIdArg)) {
158
+ errors.push('bootstrap expectedGatewayClientId must be a bounded identifier without whitespace');
159
+ }
160
+ return errors;
161
+ };
162
+
163
+ /**
164
+ * Checks a bootstrap request against its schema and the active ingress recipient, without opening
165
+ * the envelope: the recipient, the context digest of the named target and the ciphertext bound of
166
+ * `data.externalGatewayBootstrapContract.maximumTokenBytes`. Every problem is named.
167
+ */
168
+ export const validateBootstrapExternalGatewayCredentialRequest = async (
169
+ requestArg: unknown,
170
+ activeIngressRecipientArg: IActiveSecretRecipientMetadata,
171
+ ): Promise<string[]> => {
172
+ const request = readDataProperties(requestArg);
173
+ if (request === null) return ['bootstrap request must be a plain record'];
174
+ const errors: string[] = [];
175
+ if (!hasExactKeys(request, ['identity', 'expectedGatewayUrl', 'expectedGatewayClientId', 'bootstrapToken'])) {
176
+ errors.push('bootstrap request contains fields outside its schema');
177
+ }
178
+ if (!hasJwtIdentity(request.identity)) {
179
+ errors.push('bootstrap identity must be a JWT credential');
180
+ }
181
+ const targetErrors = validateExpectedGatewayTarget(
182
+ request.expectedGatewayUrl,
183
+ request.expectedGatewayClientId,
184
+ );
185
+ errors.push(...targetErrors);
186
+ const tokenInput = readDataProperties(request.bootstrapToken);
187
+ if (tokenInput === null || tokenInput.mode !== 'sealed') {
188
+ errors.push('bootstrap token must be a sealed input');
189
+ return errors;
190
+ }
191
+ const expectedContext = targetErrors.length === 0
192
+ ? secretData.createGatewayBootstrapEnvelopeContext({
193
+ gatewayUrl: request.expectedGatewayUrl as string,
194
+ gatewayClientId: request.expectedGatewayClientId as string,
195
+ })
196
+ : new Uint8Array();
197
+ errors.push(...await secretData.validateSecretValueInputForRecipient(
198
+ tokenInput,
199
+ activeIngressRecipientArg,
200
+ expectedContext,
201
+ ));
202
+ const envelope = readDataProperties(tokenInput.envelope);
203
+ if (envelope !== null
204
+ && typeof envelope.ciphertext === 'string'
205
+ && envelope.ciphertext.length
206
+ > Math.ceil(settingsData.externalGatewayBootstrapContract.maximumTokenBytes * 4 / 3)) {
207
+ errors.push(
208
+ `bootstrap token must not exceed ${settingsData.externalGatewayBootstrapContract.maximumTokenBytes} bytes`,
209
+ );
210
+ }
211
+ return errors;
212
+ };
213
+
214
+ /** Checks an enrollment state read; every problem is named. */
215
+ export const validateGetExternalGatewayEnrollmentStateRequest = (requestArg: unknown): string[] => {
216
+ const request = readDataProperties(requestArg);
217
+ if (request === null) return ['enrollment state request must be a plain record'];
218
+ const errors: string[] = [];
219
+ if (!hasExactKeys(request, ['identity'])) {
220
+ errors.push('enrollment state request contains fields outside its schema');
221
+ }
222
+ if (!hasJwtIdentity(request.identity)) {
223
+ errors.push('enrollment state identity must be a JWT credential');
224
+ }
225
+ return errors;
226
+ };