@serve.zone/interfaces 32.28.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.
@@ -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
@@ -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
+ };