@serve.zone/interfaces 32.24.0 → 32.26.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.
@@ -31,3 +31,183 @@ export const validatePlatformObjectStorageBucketName = (
31
31
  }
32
32
  return [];
33
33
  };
34
+
35
+ /**
36
+ * Where a workload reaches one node's S3 object storage, and the region it signs requests for.
37
+ *
38
+ * Non-secret and value-free: it says where, never with what. The access key and secret stay in
39
+ * the binding's sealed Secret set. Corestore is node-local, so this is a fact about the node that
40
+ * holds the bucket, stated by an administrator on `IClusterNode.data.corestoreObjectStorage`;
41
+ * Cloudly reads it from there and never derives a port, a scheme or a region.
42
+ */
43
+ export interface IPlatformObjectStorageEndpoint {
44
+ /**
45
+ * The canonical `http:` or `https:` origin of the S3 API, for example `http://192.0.2.15:9000`:
46
+ * a host, an optional port, and nothing else — no path, query, fragment, userinfo, trailing
47
+ * slash or default port. The host is the one a workload on that node can reach.
48
+ */
49
+ endpoint: string;
50
+ /** The SigV4 signing region the object storage answers for, for example `us-east-1`. */
51
+ region: string;
52
+ }
53
+
54
+ export const platformObjectStorageEndpointLimits = Object.freeze({
55
+ maximumEndpointLength: 2048,
56
+ maximumRegionLength: 64,
57
+ });
58
+
59
+ const platformObjectStorageRegionPattern = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
60
+
61
+ const isPlainObject = (valueArg: unknown): valueArg is Record<string, unknown> => (
62
+ Boolean(valueArg)
63
+ && typeof valueArg === 'object'
64
+ && !Array.isArray(valueArg)
65
+ && (Object.getPrototypeOf(valueArg) === Object.prototype
66
+ || Object.getPrototypeOf(valueArg) === null)
67
+ );
68
+
69
+ /** Validates an object-storage endpoint and names every reason it is refused. */
70
+ export const validatePlatformObjectStorageEndpoint = (
71
+ valueArg: unknown,
72
+ pathArg = 'objectstorage endpoint',
73
+ ): string[] => {
74
+ if (!isPlainObject(valueArg)
75
+ || Object.keys(valueArg).length !== 2
76
+ || !Object.hasOwn(valueArg, 'endpoint')
77
+ || !Object.hasOwn(valueArg, 'region')) {
78
+ return [`${pathArg} must use its exact schema`];
79
+ }
80
+ const errors: string[] = [];
81
+ const endpoint = valueArg.endpoint;
82
+ if (typeof endpoint !== 'string'
83
+ || endpoint.length === 0
84
+ || endpoint.length > platformObjectStorageEndpointLimits.maximumEndpointLength) {
85
+ errors.push(`${pathArg} endpoint must be a string of 1 to `
86
+ + `${platformObjectStorageEndpointLimits.maximumEndpointLength} characters`);
87
+ } else {
88
+ let parsedEndpoint: URL | undefined;
89
+ try {
90
+ parsedEndpoint = new URL(endpoint);
91
+ } catch {
92
+ errors.push(`${pathArg} endpoint must be a parsable URL`);
93
+ }
94
+ if (parsedEndpoint) {
95
+ if (parsedEndpoint.protocol !== 'http:' && parsedEndpoint.protocol !== 'https:') {
96
+ errors.push(`${pathArg} endpoint must use the http or https scheme`);
97
+ }
98
+ if (parsedEndpoint.username || parsedEndpoint.password) {
99
+ errors.push(`${pathArg} endpoint must not carry userinfo`);
100
+ }
101
+ if (parsedEndpoint.origin !== endpoint) {
102
+ errors.push(`${pathArg} endpoint must be a canonical origin without a path, query, `
103
+ + 'fragment, trailing slash or default port');
104
+ }
105
+ }
106
+ }
107
+ if (typeof valueArg.region !== 'string'
108
+ || !platformObjectStorageRegionPattern.test(valueArg.region)) {
109
+ errors.push(`${pathArg} region must be 1 to `
110
+ + `${platformObjectStorageEndpointLimits.maximumRegionLength} lowercase letters, digits `
111
+ + 'and inner hyphens');
112
+ }
113
+ return errors;
114
+ };
115
+
116
+ /**
117
+ * The non-secret environment names that tell a workload where its bucket is.
118
+ *
119
+ * These are the names Corestore has always handed a workload for a bucket, so an application that
120
+ * read them keeps working unchanged. The six credential aliases (`S3_ACCESS_KEY`,
121
+ * `S3_ACCESS_KEY_ID`, `S3_SECRET_KEY`, `S3_SECRET_ACCESS_KEY`, `AWS_ACCESS_KEY_ID`,
122
+ * `AWS_SECRET_ACCESS_KEY`) are not among them: they are Secrets and reach the workload only through
123
+ * the binding's sealed Secret set.
124
+ *
125
+ * - `S3_ENDPOINT`, `AWS_ENDPOINT_URL`: the endpoint origin, identical.
126
+ * - `S3_ENDPOINT_HOST`: the origin's host, as the URL states it (an IPv6 literal keeps its brackets).
127
+ * - `S3_PORT`: the origin's port in decimal, `80` or `443` when the origin states none.
128
+ * - `S3_USE_SSL`: `true` for an `https:` origin, `false` for `http:`.
129
+ * - `S3_REGION`, `AWS_REGION`: the signing region, identical.
130
+ * - `S3_BUCKET`: the binding's bucket, `IPlatformBinding.objectstorageBucketName`.
131
+ * - `AWS_S3_FORCE_PATH_STYLE`: always `true` — a node-local origin has no wildcard DNS and a bucket
132
+ * name may contain dots, so path-style addressing is the only one that reaches the bucket.
133
+ */
134
+ export const objectStorageEndpointEnvironmentKeys = Object.freeze([
135
+ 'S3_ENDPOINT',
136
+ 'S3_ENDPOINT_HOST',
137
+ 'S3_PORT',
138
+ 'S3_REGION',
139
+ 'S3_BUCKET',
140
+ 'S3_USE_SSL',
141
+ 'AWS_ENDPOINT_URL',
142
+ 'AWS_REGION',
143
+ 'AWS_S3_FORCE_PATH_STYLE',
144
+ ] as const);
145
+
146
+ export type TObjectStorageEndpointEnvironmentKey =
147
+ typeof objectStorageEndpointEnvironmentKeys[number];
148
+
149
+ export type TObjectStorageEndpointEnvironment = {
150
+ [TKey in TObjectStorageEndpointEnvironmentKey]: string;
151
+ };
152
+
153
+ /**
154
+ * The endpoint environment for one bucket.
155
+ *
156
+ * Throws the first reason the endpoint or the bucket name is refused; nothing is defaulted.
157
+ */
158
+ export const createObjectStorageEndpointEnvironment = (
159
+ endpointArg: IPlatformObjectStorageEndpoint,
160
+ bucketNameArg: string,
161
+ ): TObjectStorageEndpointEnvironment => {
162
+ const errors = [
163
+ ...validatePlatformObjectStorageEndpoint(endpointArg),
164
+ ...validatePlatformObjectStorageBucketName(bucketNameArg),
165
+ ];
166
+ if (errors.length > 0) throw new Error(errors[0]);
167
+ const origin = new URL(endpointArg.endpoint);
168
+ const useSsl = origin.protocol === 'https:';
169
+ return {
170
+ S3_ENDPOINT: endpointArg.endpoint,
171
+ S3_ENDPOINT_HOST: origin.hostname,
172
+ S3_PORT: origin.port || (useSsl ? '443' : '80'),
173
+ S3_REGION: endpointArg.region,
174
+ S3_BUCKET: bucketNameArg,
175
+ S3_USE_SSL: useSsl ? 'true' : 'false',
176
+ AWS_ENDPOINT_URL: endpointArg.endpoint,
177
+ AWS_REGION: endpointArg.region,
178
+ AWS_S3_FORCE_PATH_STYLE: 'true',
179
+ };
180
+ };
181
+
182
+ /**
183
+ * Validates an endpoint environment: exactly its nine keys, each value the one
184
+ * `createObjectStorageEndpointEnvironment()` derives from the same endpoint and bucket.
185
+ */
186
+ export const validateObjectStorageEndpointEnvironment = (
187
+ environmentArg: unknown,
188
+ pathArg = 'objectstorage endpoint environment',
189
+ ): string[] => {
190
+ if (!isPlainObject(environmentArg)
191
+ || Object.keys(environmentArg).length !== objectStorageEndpointEnvironmentKeys.length
192
+ || objectStorageEndpointEnvironmentKeys.some((keyArg) => (
193
+ !Object.hasOwn(environmentArg, keyArg) || typeof environmentArg[keyArg] !== 'string'
194
+ ))) {
195
+ return [`${pathArg} must contain exactly its string keys`];
196
+ }
197
+ const endpoint = { endpoint: environmentArg.S3_ENDPOINT, region: environmentArg.S3_REGION };
198
+ const errors = [
199
+ ...validatePlatformObjectStorageEndpoint(endpoint, pathArg),
200
+ ...validatePlatformObjectStorageBucketName(environmentArg.S3_BUCKET, `${pathArg} S3_BUCKET`),
201
+ ];
202
+ if (errors.length > 0) return errors;
203
+ const expected = createObjectStorageEndpointEnvironment(
204
+ endpoint as IPlatformObjectStorageEndpoint,
205
+ environmentArg.S3_BUCKET as string,
206
+ );
207
+ for (const key of objectStorageEndpointEnvironmentKeys) {
208
+ if (environmentArg[key] !== expected[key]) {
209
+ errors.push(`${pathArg} ${key} must equal the value derived from its endpoint`);
210
+ }
211
+ }
212
+ return errors;
213
+ };
@@ -0,0 +1,321 @@
1
+ import type { IPlatformBinding } from './types.js';
2
+ import type {
3
+ IPlatformObjectStorageEndpoint,
4
+ TObjectStorageEndpointEnvironment,
5
+ TObjectStorageEndpointEnvironmentKey,
6
+ } from './objectstorage.js';
7
+ import {
8
+ createObjectStorageEndpointEnvironment,
9
+ objectStorageEndpointEnvironmentKeys,
10
+ validateObjectStorageEndpointEnvironment,
11
+ validatePlatformObjectStorageBucketName,
12
+ validatePlatformObjectStorageEndpoint,
13
+ } from './objectstorage.js';
14
+ import type { TServiceRuntimeRefusal, TServiceRuntimeWaitingReason } from '../data/serviceruntime.js';
15
+ import type {
16
+ IObjectStorageRetentionEvidence,
17
+ IObjectStorageRetentionEvidenceExpectation,
18
+ IObjectStorageRetentionIntent,
19
+ TObjectStorageRetentionEnvironment,
20
+ TObjectStorageRetentionEnvironmentKey,
21
+ } from './objectstorageretention.js';
22
+ import {
23
+ createObjectStorageRetentionEnvironment,
24
+ objectStorageRetentionEnvironmentKeys,
25
+ validateObjectStorageRetentionEnvironment,
26
+ validateObjectStorageRetentionEvidence,
27
+ validateObjectStorageRetentionIntent,
28
+ } from './objectstorageretention.js';
29
+ import { isPlainRecord, readDataProperties } from '../private/dataproperties.js';
30
+
31
+ /**
32
+ * Every non-secret environment name an object-storage binding can give a workload: the nine
33
+ * endpoint names first, then the eleven retention names.
34
+ */
35
+ export const objectStorageBindingEnvironmentKeys = Object.freeze([
36
+ ...objectStorageEndpointEnvironmentKeys,
37
+ ...objectStorageRetentionEnvironmentKeys,
38
+ ] as const);
39
+
40
+ export type TObjectStorageBindingEnvironmentKey =
41
+ | TObjectStorageEndpointEnvironmentKey
42
+ | TObjectStorageRetentionEnvironmentKey;
43
+
44
+ /**
45
+ * The non-secret environment of one object-storage binding: always the endpoint names, and the
46
+ * retention names exactly when the binding declares compliance retention. Never a credential.
47
+ */
48
+ export type TObjectStorageBindingEnvironment =
49
+ | TObjectStorageEndpointEnvironment
50
+ | (TObjectStorageEndpointEnvironment & TObjectStorageRetentionEnvironment);
51
+
52
+ /** How one binding's environment resolves, in the service runtime vocabulary. */
53
+ type TBindingEnvironmentResolution =
54
+ | { status: 'composed'; environment: TObjectStorageBindingEnvironment }
55
+ | { status: 'waiting'; reason: TObjectStorageEnvironmentWaitingReason; message: string }
56
+ | {
57
+ status: 'refused';
58
+ reason: Exclude<TObjectStorageEnvironmentRefusal, 'objectstorage-environment-conflict'>;
59
+ message: string;
60
+ };
61
+
62
+ /**
63
+ * The one place a binding's environment is decided. `createObjectStorageBindingEnvironment()`
64
+ * throws its message, `composeObjectStorageServiceEnvironment()` reports its reason.
65
+ */
66
+ const resolveBindingEnvironment = async (
67
+ bindingArg: IPlatformBinding,
68
+ endpointArg: IPlatformObjectStorageEndpoint | null,
69
+ ): Promise<TBindingEnvironmentResolution> => {
70
+ const binding = readDataProperties(bindingArg);
71
+ if (binding === null || binding.capability !== 'objectstorage') {
72
+ return { status: 'refused', reason: 'objectstorage-binding-invalid',
73
+ message: 'objectstorage binding environment requires an objectstorage binding' };
74
+ }
75
+ const bindingName = typeof binding.id === 'string' ? binding.id : '(unnamed)';
76
+ if (validatePlatformObjectStorageBucketName(binding.objectstorageBucketName).length > 0) {
77
+ return { status: 'refused', reason: 'objectstorage-binding-invalid',
78
+ message: `objectstorage binding ${bindingName} carries no canonical bucket name; Cloudly owns `
79
+ + 'it and no environment invents one' };
80
+ }
81
+ if (endpointArg === null) {
82
+ return { status: 'refused', reason: 'objectstorage-endpoint-missing',
83
+ message: `objectstorage binding ${bindingName} is placed on a node that states no `
84
+ + 'corestoreObjectStorage endpoint' };
85
+ }
86
+ const endpointErrors = validatePlatformObjectStorageEndpoint(endpointArg);
87
+ if (endpointErrors.length > 0) {
88
+ return { status: 'refused', reason: 'objectstorage-endpoint-missing', message: endpointErrors[0] };
89
+ }
90
+ const bucketName = binding.objectstorageBucketName as string;
91
+ const endpointEnvironment = createObjectStorageEndpointEnvironment(endpointArg, bucketName);
92
+ const retention = binding.objectstorageRetention;
93
+ if (retention === undefined) {
94
+ return { status: 'composed', environment: endpointEnvironment };
95
+ }
96
+ const retentionBinding = readDataProperties(retention);
97
+ if (retentionBinding === null
98
+ || validateObjectStorageRetentionIntent(retentionBinding.intent).length > 0) {
99
+ return { status: 'refused', reason: 'objectstorage-binding-invalid',
100
+ message: `objectstorage binding ${bindingName} must declare canonical compliance-retention `
101
+ + 'intent' };
102
+ }
103
+ const evidence = retentionBinding.evidence;
104
+ if (evidence === undefined) {
105
+ return { status: 'waiting', reason: 'objectstorage-retention-pending',
106
+ message: `objectstorage binding ${bindingName} declares compliance retention without `
107
+ + 'verified evidence; its workload environment waits for the evidence' };
108
+ }
109
+ const authority = isPlainRecord(evidence) ? readDataProperties(evidence.authority) : null;
110
+ if (authority === null) {
111
+ return { status: 'refused', reason: 'objectstorage-binding-invalid',
112
+ message: 'objectstorage retention evidence must use its exact schema' };
113
+ }
114
+ const expectation: IObjectStorageRetentionEvidenceExpectation = {
115
+ serviceId: binding.serviceId as string,
116
+ bindingId: binding.id as string,
117
+ reconciliationGeneration: authority.reconciliationGeneration as number,
118
+ bindingRequestDigest: authority.bindingRequestDigest as string,
119
+ bucketName,
120
+ intent: retentionBinding.intent as IObjectStorageRetentionIntent,
121
+ };
122
+ const evidenceErrors = await validateObjectStorageRetentionEvidence(evidence, expectation);
123
+ if (evidenceErrors.length > 0) {
124
+ return { status: 'refused', reason: 'objectstorage-binding-invalid', message: evidenceErrors[0] };
125
+ }
126
+ const retentionEnvironment = await createObjectStorageRetentionEnvironment(
127
+ evidence as IObjectStorageRetentionEvidence,
128
+ expectation,
129
+ );
130
+ return { status: 'composed', environment: { ...endpointEnvironment, ...retentionEnvironment } };
131
+ };
132
+
133
+ /**
134
+ * The environment Cloudly composes into a workload's runtime configuration for one binding.
135
+ *
136
+ * `bindingArg` is the Cloudly binding document and `endpointArg` the
137
+ * `IClusterNode.data.corestoreObjectStorage` of the node the binding is placed on. Refused by
138
+ * name, never guessed:
139
+ *
140
+ * - a binding that is not `objectstorage` or carries no canonical `objectstorageBucketName`;
141
+ * - an endpoint that `validatePlatformObjectStorageEndpoint()` refuses;
142
+ * - a binding that declares `objectstorageRetention` without `evidence`. Its bucket's retention is
143
+ * not proven yet, so the workload is not given an environment that would let it run as if its
144
+ * bucket were ordinary storage; the caller waits for the evidence.
145
+ * - evidence that does not prove the binding's own service, binding id, bucket and intent.
146
+ *
147
+ * A binding without `objectstorageRetention` gets the endpoint names alone: it promised no
148
+ * retention, so there is nothing to state.
149
+ *
150
+ * The evidence's reconciliation generation and binding-request digest are read from its own
151
+ * authority: they are what Cloudly checked when it admitted the evidence, and the binding document
152
+ * does not repeat them. Everything the binding does state is checked again here.
153
+ *
154
+ * `composeObjectStorageServiceEnvironment()` answers the same questions with a service runtime
155
+ * reason instead of an error.
156
+ */
157
+ export const createObjectStorageBindingEnvironment = async (
158
+ bindingArg: IPlatformBinding,
159
+ endpointArg: IPlatformObjectStorageEndpoint,
160
+ ): Promise<TObjectStorageBindingEnvironment> => {
161
+ const resolution = await resolveBindingEnvironment(bindingArg, endpointArg);
162
+ if (resolution.status !== 'composed') throw new Error(resolution.message);
163
+ return resolution.environment;
164
+ };
165
+
166
+ /**
167
+ * Validates one binding's environment: the nine endpoint names, the eleven retention names all or
168
+ * none, and nothing else — so a credential alias, or any other name, is refused.
169
+ */
170
+ export const validateObjectStorageBindingEnvironment = async (
171
+ environmentArg: unknown,
172
+ pathArg = 'objectstorage binding environment',
173
+ ): Promise<string[]> => {
174
+ const environment = readDataProperties(environmentArg);
175
+ if (environment === null) {
176
+ return [`${pathArg} must be a plain record of data properties`];
177
+ }
178
+ const allowedKeys: readonly string[] = objectStorageBindingEnvironmentKeys;
179
+ const foreignKeys = Object.keys(environment).filter((keyArg) => !allowedKeys.includes(keyArg));
180
+ if (foreignKeys.length > 0) {
181
+ return [`${pathArg} must not contain ${foreignKeys.sort().join(', ')}`];
182
+ }
183
+ const endpointEnvironment: Record<string, unknown> = {};
184
+ for (const key of objectStorageEndpointEnvironmentKeys) {
185
+ if (Object.hasOwn(environment, key)) endpointEnvironment[key] = environment[key];
186
+ }
187
+ const errors = validateObjectStorageEndpointEnvironment(endpointEnvironment, pathArg);
188
+ const retentionKeys = objectStorageRetentionEnvironmentKeys.filter((keyArg) => (
189
+ Object.hasOwn(environment, keyArg)
190
+ ));
191
+ if (retentionKeys.length > 0) {
192
+ const retentionEnvironment: Record<string, unknown> = {};
193
+ for (const key of retentionKeys) retentionEnvironment[key] = environment[key];
194
+ errors.push(...await validateObjectStorageRetentionEnvironment(retentionEnvironment, pathArg));
195
+ }
196
+ return errors;
197
+ };
198
+
199
+ const findEnvironmentCollisions = (
200
+ claimedKeysArg: ReadonlySet<string>,
201
+ keysArg: readonly string[],
202
+ ): string[] => keysArg.filter((keyArg) => claimedKeysArg.has(keyArg)).sort();
203
+
204
+ const describeEnvironmentCollisions = (collisionsArg: readonly string[]): string => (
205
+ `objectstorage binding environment collides with ${collisionsArg.join(', ')} already set on the `
206
+ + 'workload'
207
+ );
208
+
209
+ /**
210
+ * A workload environment with one binding's environment added.
211
+ *
212
+ * Refuses, naming every key, when the environment already holds one of the binding's names: a
213
+ * service that sets `S3_ENDPOINT` itself, or a second object-storage binding on the same service,
214
+ * would otherwise have one value silently replace the other. The inputs are not changed.
215
+ */
216
+ export const mergeObjectStorageBindingEnvironment = (
217
+ environmentArg: Readonly<Record<string, string>>,
218
+ bindingEnvironmentArg: TObjectStorageBindingEnvironment,
219
+ ): Record<string, string> => {
220
+ const collisions = findEnvironmentCollisions(
221
+ new Set(Object.keys(environmentArg)),
222
+ Object.keys(bindingEnvironmentArg),
223
+ );
224
+ if (collisions.length > 0) throw new Error(describeEnvironmentCollisions(collisions));
225
+ return { ...environmentArg, ...bindingEnvironmentArg };
226
+ };
227
+
228
+ /** One enabled object-storage binding of a service and the endpoint of the node it is placed on. */
229
+ export interface IObjectStorageBindingEnvironmentSource {
230
+ binding: IPlatformBinding;
231
+ /**
232
+ * The `IClusterNode.data.corestoreObjectStorage` of the node the binding is placed on, `null`
233
+ * when that node states none. A binding placed on no node is not a source: its service waits as
234
+ * `bindings-pending`.
235
+ */
236
+ endpoint: IPlatformObjectStorageEndpoint | null;
237
+ }
238
+
239
+ /**
240
+ * The service runtime waiting reason an object-storage environment reports:
241
+ * `objectstorage-retention-pending`, a binding that declares compliance retention whose evidence
242
+ * has not arrived.
243
+ */
244
+ export type TObjectStorageEnvironmentWaitingReason = Extract<
245
+ TServiceRuntimeWaitingReason,
246
+ 'objectstorage-retention-pending'
247
+ >;
248
+
249
+ /**
250
+ * The service runtime refusals an object-storage environment reports.
251
+ * `objectstorage-endpoint-missing` is a binding whose node states no valid
252
+ * `corestoreObjectStorage`; `objectstorage-binding-invalid` a binding
253
+ * `createObjectStorageBindingEnvironment()` refuses (not `objectstorage`, no canonical bucket, no
254
+ * canonical retention intent, or evidence that does not prove this binding);
255
+ * `objectstorage-environment-conflict` a name the service's own environment or another binding
256
+ * already sets.
257
+ */
258
+ export type TObjectStorageEnvironmentRefusal = Extract<
259
+ TServiceRuntimeRefusal,
260
+ | 'objectstorage-endpoint-missing'
261
+ | 'objectstorage-binding-invalid'
262
+ | 'objectstorage-environment-conflict'
263
+ >;
264
+
265
+ /** A service's environment with every object-storage binding added, or the one reason it is not. */
266
+ export type TObjectStorageServiceEnvironmentComposition =
267
+ | { status: 'composed'; environment: Record<string, string> }
268
+ | {
269
+ status: 'waiting';
270
+ reason: TObjectStorageEnvironmentWaitingReason;
271
+ bindingId: string;
272
+ message: string;
273
+ }
274
+ | { status: 'refused'; reason: TObjectStorageEnvironmentRefusal; bindingId: string; message: string };
275
+
276
+ /**
277
+ * The service's own environment with the environment of every enabled object-storage binding
278
+ * added, or the service runtime reason it cannot be — so a producer reports the contract's
279
+ * verdict instead of classifying its errors.
280
+ *
281
+ * Each binding's names are `createObjectStorageBindingEnvironment()`'s from its node's endpoint,
282
+ * added as `mergeObjectStorageBindingEnvironment()` adds them. Bindings are taken in id order, so
283
+ * the verdict does not depend on the order of `sourcesArg`. A refusal outranks a wait, because a
284
+ * wait resolves on its own and a refusal never does: a binding waiting for retention evidence
285
+ * already claims all twenty of its names, so a collision with it is refused now, not once its
286
+ * evidence arrives. The first refusal is answered, otherwise the first wait, otherwise the
287
+ * composed environment. The inputs are not changed.
288
+ */
289
+ export const composeObjectStorageServiceEnvironment = async (
290
+ serviceEnvironmentArg: Readonly<Record<string, string>>,
291
+ sourcesArg: readonly IObjectStorageBindingEnvironmentSource[],
292
+ ): Promise<TObjectStorageServiceEnvironmentComposition> => {
293
+ const sources = [...sourcesArg].sort((leftArg, rightArg) => (
294
+ leftArg.binding.id < rightArg.binding.id ? -1 : leftArg.binding.id > rightArg.binding.id ? 1 : 0
295
+ ));
296
+ let environment: Record<string, string> = { ...serviceEnvironmentArg };
297
+ const claimedKeys = new Set(Object.keys(environment));
298
+ let wait: Extract<TObjectStorageServiceEnvironmentComposition, { status: 'waiting' }> | null = null;
299
+ for (const source of sources) {
300
+ const bindingId = source.binding.id;
301
+ const resolution = await resolveBindingEnvironment(source.binding, source.endpoint);
302
+ if (resolution.status === 'refused') {
303
+ return { status: 'refused', reason: resolution.reason, bindingId, message: resolution.message };
304
+ }
305
+ const keys: readonly string[] = resolution.status === 'composed'
306
+ ? Object.keys(resolution.environment)
307
+ : objectStorageBindingEnvironmentKeys;
308
+ const collisions = findEnvironmentCollisions(claimedKeys, keys);
309
+ if (collisions.length > 0) {
310
+ return { status: 'refused', reason: 'objectstorage-environment-conflict', bindingId,
311
+ message: describeEnvironmentCollisions(collisions) };
312
+ }
313
+ for (const key of keys) claimedKeys.add(key);
314
+ if (resolution.status === 'composed') {
315
+ environment = { ...environment, ...resolution.environment };
316
+ } else {
317
+ wait ??= { status: 'waiting', reason: resolution.reason, bindingId, message: resolution.message };
318
+ }
319
+ }
320
+ return wait ?? { status: 'composed', environment };
321
+ };
@@ -370,3 +370,144 @@ export const validatePlatformBindingObjectStorageRetention = async (
370
370
  }
371
371
  return validateObjectStorageRetentionBinding(bindingArg.objectstorageRetention, expectedArg);
372
372
  };
373
+
374
+ /**
375
+ * The non-secret environment names that tell a workload which retention its bucket carries.
376
+ *
377
+ * The names are the ones the cluster relay handed workloads on the Docker path, so an application
378
+ * that read them keeps working unchanged. Every value is a projection of verified
379
+ * `IObjectStorageRetentionEvidence`; none is a credential.
380
+ *
381
+ * - `S3_RETENTION_MODE`: `intent.mode`, always `compliance`.
382
+ * - `S3_RETENTION_POLICY_ID`: `intent.policyId`.
383
+ * - `S3_RETENTION_DURATION_SECONDS`: `intent.retentionDurationSeconds` in decimal.
384
+ * - `S3_RETENTION_INTENT_SHA256`: `intentSha256`.
385
+ * - `S3_RETENTION_SENTINEL_KEY`: `receipt.sentinelKey`.
386
+ * - `S3_RETENTION_CONFIGURED_AT`: `receipt.configuredAt`, Unix milliseconds in decimal.
387
+ * - `S3_RETENTION_SENTINEL_CREATED_AT`: `receipt.sentinel.createdAt`, Unix milliseconds in decimal.
388
+ * - `S3_RETENTION_SENTINEL_RETAIN_UNTIL`: `receipt.sentinel.retainUntil`, Unix milliseconds in
389
+ * decimal.
390
+ * - `S3_RETENTION_SENTINEL_PAYLOAD_SHA256`: `receipt.sentinel.payloadSha256`.
391
+ * - `S3_RETENTION_SENTINEL_METADATA_SHA256`: `receipt.sentinel.metadataSha256`.
392
+ * - `S3_RETENTION_SENTINEL_ETAG`: `receipt.sentinel.etag`.
393
+ */
394
+ export const objectStorageRetentionEnvironmentKeys = Object.freeze([
395
+ 'S3_RETENTION_MODE',
396
+ 'S3_RETENTION_POLICY_ID',
397
+ 'S3_RETENTION_DURATION_SECONDS',
398
+ 'S3_RETENTION_INTENT_SHA256',
399
+ 'S3_RETENTION_SENTINEL_KEY',
400
+ 'S3_RETENTION_CONFIGURED_AT',
401
+ 'S3_RETENTION_SENTINEL_CREATED_AT',
402
+ 'S3_RETENTION_SENTINEL_RETAIN_UNTIL',
403
+ 'S3_RETENTION_SENTINEL_PAYLOAD_SHA256',
404
+ 'S3_RETENTION_SENTINEL_METADATA_SHA256',
405
+ 'S3_RETENTION_SENTINEL_ETAG',
406
+ ] as const);
407
+
408
+ export type TObjectStorageRetentionEnvironmentKey =
409
+ typeof objectStorageRetentionEnvironmentKeys[number];
410
+
411
+ export type TObjectStorageRetentionEnvironment = {
412
+ [TKey in TObjectStorageRetentionEnvironmentKey]: string;
413
+ };
414
+
415
+ /**
416
+ * The retention environment of evidence that proves `expectedArg`.
417
+ *
418
+ * The evidence is validated against the expectation first, with the same validator every other
419
+ * consumer of retention evidence uses; the first reason it is refused is thrown. A workload is
420
+ * never told about a retention nobody proved.
421
+ */
422
+ export const createObjectStorageRetentionEnvironment = async (
423
+ evidenceArg: IObjectStorageRetentionEvidence,
424
+ expectedArg: IObjectStorageRetentionEvidenceExpectation,
425
+ ): Promise<TObjectStorageRetentionEnvironment> => {
426
+ const errors = await validateObjectStorageRetentionEvidence(evidenceArg, expectedArg);
427
+ if (errors.length > 0) throw new Error(errors[0]);
428
+ return {
429
+ S3_RETENTION_MODE: evidenceArg.intent.mode,
430
+ S3_RETENTION_POLICY_ID: evidenceArg.intent.policyId,
431
+ S3_RETENTION_DURATION_SECONDS: String(evidenceArg.intent.retentionDurationSeconds),
432
+ S3_RETENTION_INTENT_SHA256: evidenceArg.intentSha256,
433
+ S3_RETENTION_SENTINEL_KEY: evidenceArg.receipt.sentinelKey,
434
+ S3_RETENTION_CONFIGURED_AT: String(evidenceArg.receipt.configuredAt),
435
+ S3_RETENTION_SENTINEL_CREATED_AT: String(evidenceArg.receipt.sentinel.createdAt),
436
+ S3_RETENTION_SENTINEL_RETAIN_UNTIL: String(evidenceArg.receipt.sentinel.retainUntil),
437
+ S3_RETENTION_SENTINEL_PAYLOAD_SHA256: evidenceArg.receipt.sentinel.payloadSha256,
438
+ S3_RETENTION_SENTINEL_METADATA_SHA256: evidenceArg.receipt.sentinel.metadataSha256,
439
+ S3_RETENTION_SENTINEL_ETAG: evidenceArg.receipt.sentinel.etag,
440
+ };
441
+ };
442
+
443
+ const canonicalDecimalPattern = /^[1-9][0-9]*$/;
444
+
445
+ const readCanonicalPositiveInteger = (valueArg: unknown): number | undefined => (
446
+ typeof valueArg === 'string'
447
+ && canonicalDecimalPattern.test(valueArg)
448
+ && Number.isSafeInteger(Number(valueArg))
449
+ ? Number(valueArg)
450
+ : undefined
451
+ );
452
+
453
+ /**
454
+ * Validates a retention environment on its own: exactly its eleven keys, canonical values, an
455
+ * intent digest that matches the stated intent, and sentinel timestamps that agree with the
456
+ * duration. It cannot prove the authority or the bucket the evidence was bound to — the
457
+ * environment does not carry them — so it judges the shape a workload reads, not the proof.
458
+ */
459
+ export const validateObjectStorageRetentionEnvironment = async (
460
+ environmentArg: unknown,
461
+ pathArg = 'objectstorage retention environment',
462
+ ): Promise<string[]> => {
463
+ if (!isRecord(environmentArg)
464
+ || !hasExactKeys(environmentArg, objectStorageRetentionEnvironmentKeys)
465
+ || objectStorageRetentionEnvironmentKeys.some((keyArg) => (
466
+ typeof environmentArg[keyArg] !== 'string'
467
+ ))) {
468
+ return [`${pathArg} must contain exactly its string keys`];
469
+ }
470
+ const environment = environmentArg as TObjectStorageRetentionEnvironment;
471
+ const errors: string[] = [];
472
+ const duration = readCanonicalPositiveInteger(environment.S3_RETENTION_DURATION_SECONDS);
473
+ const intent = {
474
+ mode: environment.S3_RETENTION_MODE,
475
+ policyId: environment.S3_RETENTION_POLICY_ID,
476
+ retentionDurationSeconds: duration,
477
+ };
478
+ const intentErrors = validateObjectStorageRetentionIntent(intent, `${pathArg} intent`);
479
+ errors.push(...intentErrors);
480
+ if (!sha256Pattern.test(environment.S3_RETENTION_INTENT_SHA256)
481
+ || intentErrors.length === 0
482
+ && environment.S3_RETENTION_INTENT_SHA256 !== await computeObjectStorageRetentionIntentSha256(
483
+ intent as IObjectStorageRetentionIntent,
484
+ )) {
485
+ errors.push(`${pathArg} S3_RETENTION_INTENT_SHA256 must be the digest of the stated intent`);
486
+ }
487
+ if (!sentinelKeyPattern.test(environment.S3_RETENTION_SENTINEL_KEY)) {
488
+ errors.push(`${pathArg} S3_RETENTION_SENTINEL_KEY must be canonical`);
489
+ }
490
+ const configuredAt = readCanonicalPositiveInteger(environment.S3_RETENTION_CONFIGURED_AT);
491
+ const createdAt = readCanonicalPositiveInteger(environment.S3_RETENTION_SENTINEL_CREATED_AT);
492
+ const retainUntil = readCanonicalPositiveInteger(environment.S3_RETENTION_SENTINEL_RETAIN_UNTIL);
493
+ if (configuredAt === undefined
494
+ || createdAt === undefined
495
+ || retainUntil === undefined
496
+ || createdAt < configuredAt) {
497
+ errors.push(`${pathArg} timestamps must be canonical positive decimals in order`);
498
+ } else if (intentErrors.length === 0) {
499
+ try {
500
+ if (retainUntil !== createObjectStorageRetentionRetainUntil(createdAt, duration as number)) {
501
+ errors.push(`${pathArg} S3_RETENTION_SENTINEL_RETAIN_UNTIL must equal createdAt plus duration`);
502
+ }
503
+ } catch {
504
+ errors.push(`${pathArg} S3_RETENTION_SENTINEL_RETAIN_UNTIL arithmetic must be safe`);
505
+ }
506
+ }
507
+ if (!sha256Pattern.test(environment.S3_RETENTION_SENTINEL_PAYLOAD_SHA256)
508
+ || !sha256Pattern.test(environment.S3_RETENTION_SENTINEL_METADATA_SHA256)
509
+ || !etagPattern.test(environment.S3_RETENTION_SENTINEL_ETAG)) {
510
+ errors.push(`${pathArg} sentinel digests and ETag must be canonical`);
511
+ }
512
+ return errors;
513
+ };