@serve.zone/appstore 1.3.0 → 1.4.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.
@@ -0,0 +1,831 @@
1
+ import * as interfaces from '@serve.zone/interfaces';
2
+ import type {
3
+ IAppStoreStorageNormalizationOptions,
4
+ INormalizedAppStoreStorageConfig,
5
+ TAppStoreStorageFeatureId,
6
+ TAppStoreStorageRequestOrigin,
7
+ TAppStoreVersionConfig,
8
+ } from './types.js';
9
+
10
+ const storageFeatureIds = interfaces.appstore.appStoreStorageFeatureIds;
11
+ const knownStorageFeatureIds = new Set<string>(Object.values(storageFeatureIds));
12
+ const storageClassKeyPattern = /^[A-Za-z][A-Za-z0-9-]{0,62}$/;
13
+ const storageRequestIdPattern = /^[a-z][a-z0-9-]{0,62}$/;
14
+ const environmentKeyPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+ const capacityPattern = /^([1-9][0-9]*)(KiB|MiB|GiB|TiB)$/;
16
+ const capacityMultipliers = {
17
+ KiB: 1024n,
18
+ MiB: 1024n ** 2n,
19
+ GiB: 1024n ** 3n,
20
+ TiB: 1024n ** 4n,
21
+ } as const;
22
+ const legacyFilesystemClassKey = 'legacyFilesystem';
23
+ const legacyObjectStorageClassKey = 'legacyObjectStorage';
24
+ const legacyObjectStorageRequestId = 'legacy-s3';
25
+ const appStoreVersionConfigKeys = [
26
+ 'image',
27
+ 'port',
28
+ 'containerArgs',
29
+ 'secretFiles',
30
+ 'targetPorts',
31
+ 'domains',
32
+ 'publicPortMappings',
33
+ 'envVars',
34
+ 'volumes',
35
+ 'storageClasses',
36
+ 'storageRequests',
37
+ 'publishedPorts',
38
+ 'platformRequirements',
39
+ 'platformOidc',
40
+ 'minOneboxVersion',
41
+ 'minCloudlyVersion',
42
+ 'appStoreVersion',
43
+ 'upgradeStrategy',
44
+ 'source',
45
+ 'resolvedSource',
46
+ 'resolvedImageDigest',
47
+ 'changelog',
48
+ 'breaking',
49
+ 'requiresManualReview',
50
+ 'migrationRequired',
51
+ 'backupBeforeUpgrade',
52
+ 'requiresFeatures',
53
+ 'healthCheck',
54
+ ] as const;
55
+
56
+ type TPlainRecord = Record<string, unknown>;
57
+
58
+ export function parseStorageCapacityQuantity(
59
+ quantityArg: unknown,
60
+ labelArg = 'storage capacity',
61
+ ): bigint {
62
+ if (typeof quantityArg !== 'string' || quantityArg.length > 64) {
63
+ throw new Error(
64
+ `Invalid ${labelArg}: expected a positive integer followed by KiB, MiB, GiB, or TiB`,
65
+ );
66
+ }
67
+ const match = quantityArg.match(capacityPattern);
68
+ if (!match) {
69
+ throw new Error(
70
+ `Invalid ${labelArg} '${quantityArg}': expected a positive integer followed by KiB, MiB, GiB, or TiB`,
71
+ );
72
+ }
73
+ return BigInt(match[1]) * capacityMultipliers[match[2] as keyof typeof capacityMultipliers];
74
+ }
75
+
76
+ /**
77
+ * Strict structural validation plus deterministic legacy normalization. This
78
+ * does not claim that a runtime can fulfill explicit requests; installers must
79
+ * call normalizeAppStoreStorageConfig with an advertised feature set.
80
+ */
81
+ export function validateAppStoreStorageConfig(
82
+ configArg: TAppStoreVersionConfig,
83
+ labelArg = 'appstore config',
84
+ ): INormalizedAppStoreStorageConfig {
85
+ return validateAndNormalizeStorageConfig(configArg, labelArg);
86
+ }
87
+
88
+ /**
89
+ * Canonical pre-fulfillment handoff. Explicit storage contracts fail closed
90
+ * unless every derived storage feature is advertised by the target runtime.
91
+ */
92
+ export function normalizeAppStoreStorageConfig(
93
+ configArg: TAppStoreVersionConfig,
94
+ optionsArg: IAppStoreStorageNormalizationOptions,
95
+ labelArg = 'appstore config',
96
+ ): INormalizedAppStoreStorageConfig {
97
+ const normalized = validateAndNormalizeStorageConfig(configArg, labelArg);
98
+ if (!optionsArg || optionsArg.supportedFeatures === undefined) {
99
+ throw new Error(`Invalid ${labelArg}: supportedFeatures is required for storage feature negotiation`);
100
+ }
101
+
102
+ let supportedFeatures: Set<string>;
103
+ try {
104
+ supportedFeatures = new Set(optionsArg.supportedFeatures);
105
+ } catch {
106
+ throw new Error(`Invalid ${labelArg}: supportedFeatures must be iterable`);
107
+ }
108
+ const unsupportedFeatures = normalized.requiredFeatureIds.filter(
109
+ (featureIdArg) => !supportedFeatures.has(featureIdArg),
110
+ );
111
+ if (unsupportedFeatures.length > 0) {
112
+ throw new Error(
113
+ `Unsupported ${labelArg} storage features: ${unsupportedFeatures.join(', ')}`,
114
+ );
115
+ }
116
+ return normalized;
117
+ }
118
+
119
+ function validateAndNormalizeStorageConfig(
120
+ configArg: TAppStoreVersionConfig,
121
+ labelArg: string,
122
+ ): INormalizedAppStoreStorageConfig {
123
+ assertExactKeys(
124
+ assertPlainRecord(configArg, labelArg),
125
+ appStoreVersionConfigKeys,
126
+ labelArg,
127
+ );
128
+ validatePlatformRequirements(configArg.platformRequirements, labelArg);
129
+ const explicitClassInput = configArg.storageClasses;
130
+ const explicitRequestInput = configArg.storageRequests;
131
+ const hasExplicitClasses = explicitClassInput !== undefined;
132
+ const hasExplicitRequests = explicitRequestInput !== undefined;
133
+ if (hasExplicitClasses !== hasExplicitRequests) {
134
+ throw new Error(
135
+ `Invalid ${labelArg}: storageClasses and storageRequests must be declared together`,
136
+ );
137
+ }
138
+
139
+ const storageClasses = validateStorageClasses(explicitClassInput, labelArg);
140
+ const explicitRequests = validateExplicitStorageRequests(
141
+ explicitRequestInput,
142
+ storageClasses,
143
+ configArg,
144
+ labelArg,
145
+ );
146
+ const requiredFeatureIds = validateFeatureDeclaration(configArg, explicitRequests, labelArg);
147
+
148
+ if (configArg.platformRequirements?.s3 && explicitRequests.some(
149
+ (requestArg) => requestArg.kind === 'objectStorage',
150
+ )) {
151
+ throw new Error(
152
+ `Invalid ${labelArg}: platformRequirements.s3 cannot coexist with explicit objectStorage requests`,
153
+ );
154
+ }
155
+
156
+ const storageRequests = [...explicitRequests];
157
+ const requestOriginEntries: Array<[string, TAppStoreStorageRequestOrigin]> = explicitRequests.map(
158
+ (requestArg) => [requestArg.id, 'explicit'],
159
+ );
160
+ const requestIds = new Set(explicitRequests.map((requestArg) => requestArg.id));
161
+ const mountPaths = explicitRequests
162
+ .filter((requestArg): requestArg is interfaces.appstore.IAppStoreFilesystemStorageRequest => (
163
+ requestArg.kind === 'filesystem'
164
+ ))
165
+ .map((requestArg) => requestArg.mountPath);
166
+
167
+ const legacyVolumes = normalizeLegacyVolumes(configArg.volumes, labelArg);
168
+ if (legacyVolumes.length > 0) {
169
+ if (Object.hasOwn(storageClasses, legacyFilesystemClassKey)) {
170
+ throw new Error(
171
+ `Invalid ${labelArg}: storageClasses key '${legacyFilesystemClassKey}' is reserved for legacy volume normalization`,
172
+ );
173
+ }
174
+ storageClasses[legacyFilesystemClassKey] = {
175
+ kind: 'filesystem',
176
+ purpose: 'runtime',
177
+ required: {
178
+ durability: 'persistent',
179
+ ...(legacyVolumes.some((volumeArg) => volumeArg.backup !== false)
180
+ ? { backup: true as const }
181
+ : {}),
182
+ },
183
+ };
184
+ }
185
+
186
+ for (const [indexArg, volumeArg] of legacyVolumes.entries()) {
187
+ const requestId = createLegacyVolumeRequestId(volumeArg, indexArg, labelArg);
188
+ if (requestIds.has(requestId)) {
189
+ throw new Error(`Invalid ${labelArg}: storage request id '${requestId}' is duplicated`);
190
+ }
191
+ assertNoPathCollision(volumeArg.mountPath, mountPaths, `${labelArg} legacy volume ${indexArg + 1}`);
192
+ requestIds.add(requestId);
193
+ mountPaths.push(volumeArg.mountPath);
194
+ storageRequests.push({
195
+ id: requestId,
196
+ kind: 'filesystem',
197
+ storageClass: legacyFilesystemClassKey,
198
+ mountPath: volumeArg.mountPath,
199
+ accessMode: volumeArg.readOnly ? 'ReadOnlyMany' : 'ReadWriteOnce',
200
+ reclaimPolicy: 'retain',
201
+ ...(volumeArg.backup === false ? {} : { protection: { backup: 'required' as const } }),
202
+ });
203
+ requestOriginEntries.push([requestId, 'legacyVolume']);
204
+ }
205
+
206
+ let legacyCompatibility: INormalizedAppStoreStorageConfig['legacyCompatibility'];
207
+ if (configArg.platformRequirements?.s3) {
208
+ if (Object.hasOwn(storageClasses, legacyObjectStorageClassKey)) {
209
+ throw new Error(
210
+ `Invalid ${labelArg}: storageClasses key '${legacyObjectStorageClassKey}' is reserved for legacy S3 normalization`,
211
+ );
212
+ }
213
+ if (requestIds.has(legacyObjectStorageRequestId)) {
214
+ throw new Error(
215
+ `Invalid ${labelArg}: storage request id '${legacyObjectStorageRequestId}' is reserved for legacy S3 normalization`,
216
+ );
217
+ }
218
+ storageClasses[legacyObjectStorageClassKey] = {
219
+ kind: 'objectStorage',
220
+ purpose: 'runtime',
221
+ required: { durability: 'persistent' },
222
+ };
223
+ storageRequests.push({
224
+ id: legacyObjectStorageRequestId,
225
+ kind: 'objectStorage',
226
+ storageClass: legacyObjectStorageClassKey,
227
+ accessMode: 'readWrite',
228
+ reclaimPolicy: 'retain',
229
+ delivery: {
230
+ type: 'environment',
231
+ keys: {
232
+ endpoint: 'S3_ENDPOINT',
233
+ bucket: 'S3_BUCKET',
234
+ region: 'S3_REGION',
235
+ accessKeyId: 'S3_ACCESS_KEY',
236
+ secretAccessKey: 'S3_SECRET_KEY',
237
+ },
238
+ },
239
+ });
240
+ requestOriginEntries.push([legacyObjectStorageRequestId, 'legacyS3']);
241
+ legacyCompatibility = {
242
+ objectStorage: {
243
+ requestId: legacyObjectStorageRequestId,
244
+ deliveryProfile: 'servezone-platform-s3-v1',
245
+ environmentOwnership: 'bindingOverridesTemplate',
246
+ },
247
+ };
248
+ }
249
+
250
+ validateAllFilesystemMountsAndSecretTargets(storageRequests, configArg, labelArg);
251
+
252
+ return {
253
+ schemaVersion: 1,
254
+ storageClasses,
255
+ storageRequests,
256
+ requestOrigins: Object.fromEntries(requestOriginEntries),
257
+ requiredFeatureIds,
258
+ ...(legacyCompatibility ? { legacyCompatibility } : {}),
259
+ };
260
+ }
261
+
262
+ function validateStorageClasses(
263
+ classesArg: TAppStoreVersionConfig['storageClasses'],
264
+ labelArg: string,
265
+ ): Record<string, interfaces.appstore.IAppStoreStorageClass> {
266
+ if (classesArg === undefined) return {};
267
+ const classesRecord = assertPlainRecord(classesArg, `${labelArg} storageClasses`);
268
+ const entries = Object.entries(classesRecord);
269
+ if (entries.length === 0 || entries.length > 64) {
270
+ throw new Error(`Invalid ${labelArg}: storageClasses must contain between 1 and 64 entries`);
271
+ }
272
+
273
+ const normalizedEntries = entries.map(([classKeyArg, candidateArg]) => {
274
+ if (!storageClassKeyPattern.test(classKeyArg)) {
275
+ throw new Error(`Invalid ${labelArg}: storage class key '${classKeyArg}' is not stable`);
276
+ }
277
+ const classLabel = `${labelArg} storageClasses.${classKeyArg}`;
278
+ const candidate = assertPlainRecord(candidateArg, classLabel);
279
+ assertExactKeys(candidate, ['kind', 'purpose', 'required', 'preferred'], classLabel);
280
+ if (!['filesystem', 'objectStorage'].includes(candidate.kind as string)) {
281
+ throw new Error(`Invalid ${classLabel}: kind must be filesystem or objectStorage`);
282
+ }
283
+ if (!['runtime', 'database', 'registry', 'backup'].includes(candidate.purpose as string)) {
284
+ throw new Error(`Invalid ${classLabel}: purpose is not supported`);
285
+ }
286
+ const required = validateClassRequirement(candidate.required, `${classLabel}.required`);
287
+ const preferred = validateClassRequirement(candidate.preferred, `${classLabel}.preferred`);
288
+ if (required && preferred) {
289
+ for (const requirementKey of Object.keys(required) as Array<keyof typeof required>) {
290
+ if (preferred[requirementKey] !== undefined
291
+ && preferred[requirementKey] !== required[requirementKey]) {
292
+ throw new Error(
293
+ `Invalid ${classLabel}: preferred.${requirementKey} conflicts with required.${requirementKey}`,
294
+ );
295
+ }
296
+ }
297
+ }
298
+ const normalized: interfaces.appstore.IAppStoreStorageClass = {
299
+ kind: candidate.kind as interfaces.platform.TStorageResourceKind,
300
+ purpose: candidate.purpose as interfaces.appstore.TAppStoreStoragePurpose,
301
+ ...(required ? { required } : {}),
302
+ ...(preferred ? { preferred } : {}),
303
+ };
304
+ return [classKeyArg, normalized] as const;
305
+ });
306
+
307
+ return Object.fromEntries(normalizedEntries);
308
+ }
309
+
310
+ function validateClassRequirement(
311
+ requirementArg: unknown,
312
+ labelArg: string,
313
+ ): interfaces.platform.IStorageClassRequirement | undefined {
314
+ if (requirementArg === undefined) return undefined;
315
+ const requirement = assertPlainRecord(requirementArg, labelArg);
316
+ assertExactKeys(requirement, [
317
+ 'performanceTier',
318
+ 'durability',
319
+ 'topology',
320
+ 'hardQuota',
321
+ 'snapshots',
322
+ 'backup',
323
+ 'encryptedInTransit',
324
+ ], labelArg);
325
+ assertOptionalMember(
326
+ requirement.performanceTier,
327
+ ['standard', 'highIops', 'capacity'],
328
+ `${labelArg}.performanceTier`,
329
+ );
330
+ assertOptionalMember(
331
+ requirement.durability,
332
+ ['ephemeral', 'persistent'],
333
+ `${labelArg}.durability`,
334
+ );
335
+ assertOptionalMember(
336
+ requirement.topology,
337
+ ['singleNode', 'multiNode'],
338
+ `${labelArg}.topology`,
339
+ );
340
+ assertOptionalMember(requirement.snapshots, ['portable', 'native'], `${labelArg}.snapshots`);
341
+ for (const booleanKey of ['hardQuota', 'backup', 'encryptedInTransit'] as const) {
342
+ if (requirement[booleanKey] !== undefined && requirement[booleanKey] !== true) {
343
+ throw new Error(`Invalid ${labelArg}.${booleanKey}: only true may be declared`);
344
+ }
345
+ }
346
+ return { ...requirement } as interfaces.platform.IStorageClassRequirement;
347
+ }
348
+
349
+ function validateExplicitStorageRequests(
350
+ requestsArg: TAppStoreVersionConfig['storageRequests'],
351
+ classesArg: Record<string, interfaces.appstore.IAppStoreStorageClass>,
352
+ configArg: TAppStoreVersionConfig,
353
+ labelArg: string,
354
+ ): interfaces.appstore.TAppStoreStorageRequest[] {
355
+ if (requestsArg === undefined) return [];
356
+ if (!Array.isArray(requestsArg) || requestsArg.length === 0 || requestsArg.length > 64) {
357
+ throw new Error(`Invalid ${labelArg}: storageRequests must contain between 1 and 64 entries`);
358
+ }
359
+
360
+ const requestIds = new Set<string>();
361
+ const usedClassKeys = new Set<string>();
362
+ const deliveryEnvironmentKeys = new Set<string>();
363
+ const declaredEnvironmentKeys = new Set<string>();
364
+ for (const envVar of configArg.envVars || []) {
365
+ declaredEnvironmentKeys.add(envVar.key);
366
+ }
367
+ for (const secretFile of configArg.secretFiles || []) {
368
+ declaredEnvironmentKeys.add(secretFile.sourceKey);
369
+ }
370
+ for (const oidcEnvironmentKey of Object.values(
371
+ configArg.platformOidc?.environmentVariables || {},
372
+ )) {
373
+ declaredEnvironmentKeys.add(oidcEnvironmentKey);
374
+ }
375
+ const deliverySecretPaths = new Set<string>();
376
+ const declaredSecretPaths = new Set((configArg.secretFiles || []).map(
377
+ (entryArg) => entryArg.targetPath,
378
+ ));
379
+
380
+ const requests = requestsArg.map((candidateArg, indexArg) => {
381
+ const requestLabel = `${labelArg} storageRequests[${indexArg}]`;
382
+ const candidate = assertPlainRecord(candidateArg, requestLabel);
383
+ if (candidate.kind === 'filesystem') {
384
+ assertExactKeys(candidate, [
385
+ 'id', 'kind', 'storageClass', 'capacity', 'reclaimPolicy',
386
+ 'mountPath', 'accessMode', 'protection',
387
+ ], requestLabel);
388
+ } else if (candidate.kind === 'objectStorage') {
389
+ assertExactKeys(candidate, [
390
+ 'id', 'kind', 'storageClass', 'capacity', 'reclaimPolicy',
391
+ 'accessMode', 'delivery', 'protection',
392
+ ], requestLabel);
393
+ } else {
394
+ throw new Error(`Invalid ${requestLabel}: kind must be filesystem or objectStorage`);
395
+ }
396
+ if (typeof candidate.id !== 'string' || !storageRequestIdPattern.test(candidate.id)) {
397
+ throw new Error(`Invalid ${requestLabel}: id must be a stable lowercase identifier`);
398
+ }
399
+ if (requestIds.has(candidate.id)) {
400
+ throw new Error(`Invalid ${labelArg}: storage request id '${candidate.id}' is duplicated`);
401
+ }
402
+ requestIds.add(candidate.id);
403
+ if (typeof candidate.storageClass !== 'string'
404
+ || !Object.hasOwn(classesArg, candidate.storageClass)) {
405
+ throw new Error(`Invalid ${requestLabel}: storageClass '${candidate.storageClass}' is not declared`);
406
+ }
407
+ const storageClass = classesArg[candidate.storageClass];
408
+ if (storageClass.kind !== candidate.kind) {
409
+ throw new Error(`Invalid ${requestLabel}: storageClass kind does not match request kind`);
410
+ }
411
+ usedClassKeys.add(candidate.storageClass);
412
+ if (!['retain', 'delete'].includes(candidate.reclaimPolicy as string)) {
413
+ throw new Error(`Invalid ${requestLabel}: reclaimPolicy must be retain or delete`);
414
+ }
415
+ const capacity = validateCapacity(candidate.capacity, `${requestLabel}.capacity`);
416
+ if (capacity?.limit && storageClass.required?.hardQuota !== true) {
417
+ throw new Error(`Invalid ${requestLabel}: capacity.limit requires storageClass.required.hardQuota`);
418
+ }
419
+
420
+ if (candidate.kind === 'filesystem') {
421
+ if (typeof candidate.mountPath !== 'string'
422
+ || interfaces.data.normalizeServiceAbsolutePath(candidate.mountPath) !== candidate.mountPath
423
+ || candidate.mountPath === '/'
424
+ || pathsOverlap(candidate.mountPath, '/run/secrets')) {
425
+ throw new Error(`Invalid ${requestLabel}: mountPath must be a canonical non-root path outside /run/secrets`);
426
+ }
427
+ assertOptionalMember(
428
+ candidate.accessMode,
429
+ ['ReadWriteOnce', 'ReadOnlyMany', 'ReadWriteMany'],
430
+ `${requestLabel}.accessMode`,
431
+ false,
432
+ );
433
+ const protection = validateFilesystemProtection(
434
+ candidate.protection,
435
+ `${requestLabel}.protection`,
436
+ );
437
+ if (protection?.backup === 'required' && storageClass.required?.backup !== true) {
438
+ throw new Error(`Invalid ${requestLabel}: required backup must be declared by the storage class`);
439
+ }
440
+ if (protection?.snapshots && storageClass.required?.snapshots !== protection.snapshots) {
441
+ throw new Error(`Invalid ${requestLabel}: required snapshots must be declared by the storage class`);
442
+ }
443
+ return {
444
+ id: candidate.id,
445
+ kind: 'filesystem',
446
+ storageClass: candidate.storageClass,
447
+ ...(capacity ? { capacity } : {}),
448
+ reclaimPolicy: candidate.reclaimPolicy as interfaces.platform.TStorageReclaimPolicy,
449
+ mountPath: candidate.mountPath,
450
+ accessMode: candidate.accessMode as interfaces.platform.TFilesystemStorageAccessMode,
451
+ ...(protection ? { protection } : {}),
452
+ } satisfies interfaces.appstore.IAppStoreFilesystemStorageRequest;
453
+ }
454
+
455
+ assertOptionalMember(
456
+ candidate.accessMode,
457
+ ['readOnly', 'readWrite'],
458
+ `${requestLabel}.accessMode`,
459
+ false,
460
+ );
461
+ const delivery = validateObjectStorageDelivery(
462
+ candidate.delivery,
463
+ requestLabel,
464
+ declaredEnvironmentKeys,
465
+ deliveryEnvironmentKeys,
466
+ declaredSecretPaths,
467
+ deliverySecretPaths,
468
+ );
469
+ const protection = validateObjectStorageProtection(
470
+ candidate.protection,
471
+ `${requestLabel}.protection`,
472
+ );
473
+ if (protection?.backup === 'required' && storageClass.required?.backup !== true) {
474
+ throw new Error(`Invalid ${requestLabel}: required backup must be declared by the storage class`);
475
+ }
476
+ return {
477
+ id: candidate.id,
478
+ kind: 'objectStorage',
479
+ storageClass: candidate.storageClass,
480
+ ...(capacity ? { capacity } : {}),
481
+ reclaimPolicy: candidate.reclaimPolicy as interfaces.platform.TStorageReclaimPolicy,
482
+ accessMode: candidate.accessMode as interfaces.platform.TObjectStorageAccessMode,
483
+ delivery,
484
+ ...(protection ? { protection } : {}),
485
+ } satisfies interfaces.appstore.IAppStoreObjectStorageRequest;
486
+ });
487
+
488
+ for (const classKeyArg of Object.keys(classesArg)) {
489
+ if (!usedClassKeys.has(classKeyArg)) {
490
+ throw new Error(`Invalid ${labelArg}: storage class '${classKeyArg}' is not used by a request`);
491
+ }
492
+ }
493
+ return requests;
494
+ }
495
+
496
+ function validateCapacity(
497
+ capacityArg: unknown,
498
+ labelArg: string,
499
+ ): interfaces.platform.IStorageCapacityRequest | undefined {
500
+ if (capacityArg === undefined) return undefined;
501
+ const capacity = assertPlainRecord(capacityArg, labelArg);
502
+ assertExactKeys(capacity, ['request', 'limit'], labelArg);
503
+ const requestedBytes = parseStorageCapacityQuantity(capacity.request, `${labelArg}.request`);
504
+ const limitBytes = capacity.limit === undefined
505
+ ? undefined
506
+ : parseStorageCapacityQuantity(capacity.limit, `${labelArg}.limit`);
507
+ if (limitBytes !== undefined && limitBytes < requestedBytes) {
508
+ throw new Error(`Invalid ${labelArg}: limit must be greater than or equal to request`);
509
+ }
510
+ return {
511
+ request: capacity.request as string,
512
+ ...(capacity.limit === undefined ? {} : { limit: capacity.limit as string }),
513
+ };
514
+ }
515
+
516
+ function validateFilesystemProtection(
517
+ protectionArg: unknown,
518
+ labelArg: string,
519
+ ): interfaces.appstore.IAppStoreFilesystemProtection | undefined {
520
+ if (protectionArg === undefined) return undefined;
521
+ const protection = assertPlainRecord(protectionArg, labelArg);
522
+ assertExactKeys(protection, ['backup', 'snapshots', 'consistency'], labelArg);
523
+ assertOptionalMember(protection.backup, ['required'], `${labelArg}.backup`);
524
+ assertOptionalMember(protection.snapshots, ['portable', 'native'], `${labelArg}.snapshots`);
525
+ assertOptionalMember(
526
+ protection.consistency,
527
+ ['crashConsistent', 'applicationConsistent'],
528
+ `${labelArg}.consistency`,
529
+ );
530
+ return { ...protection } as interfaces.appstore.IAppStoreFilesystemProtection;
531
+ }
532
+
533
+ function validateObjectStorageProtection(
534
+ protectionArg: unknown,
535
+ labelArg: string,
536
+ ): interfaces.appstore.IAppStoreObjectStorageProtection | undefined {
537
+ if (protectionArg === undefined) return undefined;
538
+ const protection = assertPlainRecord(protectionArg, labelArg);
539
+ assertExactKeys(protection, ['backup', 'versioning', 'retentionDays'], labelArg);
540
+ assertOptionalMember(protection.backup, ['required'], `${labelArg}.backup`);
541
+ assertOptionalMember(protection.versioning, ['required'], `${labelArg}.versioning`);
542
+ if (protection.retentionDays !== undefined
543
+ && (!Number.isInteger(protection.retentionDays)
544
+ || (protection.retentionDays as number) < 1
545
+ || (protection.retentionDays as number) > 36500)) {
546
+ throw new Error(`Invalid ${labelArg}.retentionDays: expected an integer between 1 and 36500`);
547
+ }
548
+ return { ...protection } as interfaces.appstore.IAppStoreObjectStorageProtection;
549
+ }
550
+
551
+ function validateObjectStorageDelivery(
552
+ deliveryArg: unknown,
553
+ requestLabelArg: string,
554
+ declaredEnvironmentKeysArg: Set<string>,
555
+ deliveryEnvironmentKeysArg: Set<string>,
556
+ declaredSecretPathsArg: Set<string>,
557
+ deliverySecretPathsArg: Set<string>,
558
+ ): interfaces.platform.TObjectStorageDelivery {
559
+ const deliveryLabel = `${requestLabelArg}.delivery`;
560
+ const delivery = assertPlainRecord(deliveryArg, deliveryLabel);
561
+ if (delivery.type === 'environment') {
562
+ assertExactKeys(delivery, ['type', 'keys'], deliveryLabel);
563
+ const keys = assertPlainRecord(delivery.keys, `${deliveryLabel}.keys`);
564
+ assertExactKeys(keys, [
565
+ 'endpoint', 'bucket', 'region', 'accessKeyId', 'secretAccessKey', 'sessionToken',
566
+ ], `${deliveryLabel}.keys`);
567
+ for (const requiredKey of ['endpoint', 'bucket', 'region', 'accessKeyId', 'secretAccessKey']) {
568
+ if (typeof keys[requiredKey] !== 'string') {
569
+ throw new Error(`Invalid ${deliveryLabel}.keys: '${requiredKey}' is required`);
570
+ }
571
+ }
572
+ const targetKeys = Object.values(keys);
573
+ const localKeys = new Set<string>();
574
+ for (const targetKeyArg of targetKeys) {
575
+ if (typeof targetKeyArg !== 'string' || !environmentKeyPattern.test(targetKeyArg)) {
576
+ throw new Error(`Invalid ${deliveryLabel}: environment delivery keys must be canonical`);
577
+ }
578
+ if (localKeys.has(targetKeyArg)) {
579
+ throw new Error(`Invalid ${deliveryLabel}: environment key '${targetKeyArg}' is duplicated`);
580
+ }
581
+ if (declaredEnvironmentKeysArg.has(targetKeyArg)) {
582
+ throw new Error(
583
+ `Invalid ${deliveryLabel}: environment key '${targetKeyArg}' collides with an app runtime input`,
584
+ );
585
+ }
586
+ if (deliveryEnvironmentKeysArg.has(targetKeyArg)) {
587
+ throw new Error(`Invalid ${deliveryLabel}: environment key '${targetKeyArg}' is used by another storage request`);
588
+ }
589
+ localKeys.add(targetKeyArg);
590
+ deliveryEnvironmentKeysArg.add(targetKeyArg);
591
+ }
592
+ return {
593
+ type: 'environment',
594
+ keys: { ...keys },
595
+ } as interfaces.platform.IObjectStorageEnvironmentDelivery;
596
+ }
597
+
598
+ if (delivery.type === 'secretFile') {
599
+ assertExactKeys(delivery, ['type', 'targetPath', 'format'], deliveryLabel);
600
+ if (delivery.format !== 'servezone-object-storage-v1') {
601
+ throw new Error(`Invalid ${deliveryLabel}: secret file format is not supported`);
602
+ }
603
+ if (typeof delivery.targetPath !== 'string'
604
+ || interfaces.data.normalizeServiceAbsolutePath(delivery.targetPath) !== delivery.targetPath
605
+ || !/^\/run\/secrets\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(delivery.targetPath)
606
+ || delivery.targetPath === '/run/secrets/secret.json') {
607
+ throw new Error(`Invalid ${deliveryLabel}: targetPath must be a unique canonical file below /run/secrets`);
608
+ }
609
+ if (declaredSecretPathsArg.has(delivery.targetPath)) {
610
+ throw new Error(`Invalid ${deliveryLabel}: targetPath collides with secretFiles`);
611
+ }
612
+ if (deliverySecretPathsArg.has(delivery.targetPath)) {
613
+ throw new Error(`Invalid ${deliveryLabel}: targetPath is used by another storage request`);
614
+ }
615
+ deliverySecretPathsArg.add(delivery.targetPath);
616
+ return {
617
+ type: 'secretFile',
618
+ targetPath: delivery.targetPath,
619
+ format: 'servezone-object-storage-v1',
620
+ };
621
+ }
622
+
623
+ throw new Error(`Invalid ${deliveryLabel}: type must be environment or secretFile`);
624
+ }
625
+
626
+ function validateFeatureDeclaration(
627
+ configArg: TAppStoreVersionConfig,
628
+ explicitRequestsArg: interfaces.appstore.TAppStoreStorageRequest[],
629
+ labelArg: string,
630
+ ): TAppStoreStorageFeatureId[] {
631
+ const requiredFeatures = new Set<TAppStoreStorageFeatureId>();
632
+ if (explicitRequestsArg.length > 0) {
633
+ requiredFeatures.add(storageFeatureIds.bindingsV1);
634
+ }
635
+ if (explicitRequestsArg.some((requestArg) => requestArg.kind === 'filesystem')) {
636
+ requiredFeatures.add(storageFeatureIds.filesystemV1);
637
+ }
638
+ if (explicitRequestsArg.some((requestArg) => requestArg.kind === 'objectStorage')) {
639
+ requiredFeatures.add(storageFeatureIds.objectStorageV1);
640
+ }
641
+ if (explicitRequestsArg.some((requestArg) => (
642
+ requestArg.kind === 'objectStorage' && requestArg.delivery.type === 'secretFile'
643
+ ))) {
644
+ requiredFeatures.add(storageFeatureIds.objectStorageSecretFileV1);
645
+ }
646
+
647
+ const declaredFeatures = configArg.requiresFeatures || [];
648
+ if (!Array.isArray(declaredFeatures)
649
+ || declaredFeatures.some((entryArg) => typeof entryArg !== 'string')) {
650
+ throw new Error(`Invalid ${labelArg}: requiresFeatures must be an array of strings`);
651
+ }
652
+ if (new Set(declaredFeatures).size !== declaredFeatures.length) {
653
+ throw new Error(`Invalid ${labelArg}: requiresFeatures must not contain duplicates`);
654
+ }
655
+ for (const requiredFeature of requiredFeatures) {
656
+ if (!declaredFeatures.includes(requiredFeature)) {
657
+ throw new Error(`Invalid ${labelArg}: requiresFeatures is missing '${requiredFeature}'`);
658
+ }
659
+ }
660
+ for (const declaredFeature of declaredFeatures) {
661
+ if (declaredFeature.startsWith('storage.') && !knownStorageFeatureIds.has(declaredFeature)) {
662
+ throw new Error(`Invalid ${labelArg}: unknown storage feature '${declaredFeature}'`);
663
+ }
664
+ if (knownStorageFeatureIds.has(declaredFeature)
665
+ && !requiredFeatures.has(declaredFeature as TAppStoreStorageFeatureId)) {
666
+ throw new Error(`Invalid ${labelArg}: storage feature '${declaredFeature}' is not used`);
667
+ }
668
+ }
669
+ return Array.from(requiredFeatures);
670
+ }
671
+
672
+ function validatePlatformRequirements(
673
+ requirementsArg: TAppStoreVersionConfig['platformRequirements'],
674
+ labelArg: string,
675
+ ): void {
676
+ if (requirementsArg === undefined) return;
677
+ const requirements = assertPlainRecord(requirementsArg, `${labelArg} platformRequirements`);
678
+ assertExactKeys(
679
+ requirements,
680
+ ['mongodb', 's3', 'clickhouse', 'valkey', 'mariadb'],
681
+ `${labelArg} platformRequirements`,
682
+ );
683
+ for (const [keyArg, valueArg] of Object.entries(requirements)) {
684
+ if (typeof valueArg !== 'boolean') {
685
+ throw new Error(`Invalid ${labelArg} platformRequirements.${keyArg}: expected a boolean`);
686
+ }
687
+ }
688
+ }
689
+
690
+ function normalizeLegacyVolumes(
691
+ volumesArg: TAppStoreVersionConfig['volumes'],
692
+ labelArg: string,
693
+ ): interfaces.appstore.IAppStoreVolume[] {
694
+ if (volumesArg === undefined) return [];
695
+ if (!Array.isArray(volumesArg) || volumesArg.length > 64) {
696
+ throw new Error(`Invalid ${labelArg}: volumes must be an array with at most 64 entries`);
697
+ }
698
+ return volumesArg.map((volumeArg, indexArg) => {
699
+ const volumeLabel = `${labelArg} volumes[${indexArg}]`;
700
+ const normalized = typeof volumeArg === 'string'
701
+ ? { mountPath: volumeArg }
702
+ : assertPlainRecord(volumeArg, volumeLabel);
703
+ assertExactKeys(
704
+ normalized,
705
+ ['name', 'source', 'mountPath', 'driver', 'readOnly', 'backup', 'options'],
706
+ volumeLabel,
707
+ );
708
+ for (const forbiddenKey of ['source', 'driver', 'options']) {
709
+ if (normalized[forbiddenKey] !== undefined) {
710
+ throw new Error(`Invalid ${volumeLabel}: physical field '${forbiddenKey}' cannot be normalized portably`);
711
+ }
712
+ }
713
+ if (typeof normalized.mountPath !== 'string'
714
+ || interfaces.data.normalizeServiceAbsolutePath(normalized.mountPath) !== normalized.mountPath
715
+ || normalized.mountPath === '/'
716
+ || pathsOverlap(normalized.mountPath, '/run/secrets')) {
717
+ throw new Error(`Invalid ${volumeLabel}: mountPath must be a canonical non-root path outside /run/secrets`);
718
+ }
719
+ if (normalized.name !== undefined
720
+ && (typeof normalized.name !== 'string' || !storageRequestIdPattern.test(normalized.name))) {
721
+ throw new Error(`Invalid ${volumeLabel}: name must be a stable lowercase identifier`);
722
+ }
723
+ for (const booleanKey of ['readOnly', 'backup']) {
724
+ if (normalized[booleanKey] !== undefined && typeof normalized[booleanKey] !== 'boolean') {
725
+ throw new Error(`Invalid ${volumeLabel}.${booleanKey}: expected a boolean`);
726
+ }
727
+ }
728
+ return {
729
+ ...(normalized.name === undefined ? {} : { name: normalized.name as string }),
730
+ mountPath: normalized.mountPath,
731
+ ...(normalized.readOnly === undefined ? {} : { readOnly: normalized.readOnly as boolean }),
732
+ ...(normalized.backup === undefined ? {} : { backup: normalized.backup as boolean }),
733
+ };
734
+ });
735
+ }
736
+
737
+ function createLegacyVolumeRequestId(
738
+ volumeArg: interfaces.appstore.IAppStoreVolume,
739
+ indexArg: number,
740
+ labelArg: string,
741
+ ): string {
742
+ const stablePart = volumeArg.name || volumeArg.mountPath
743
+ .toLowerCase()
744
+ .split('/')
745
+ .filter(Boolean)
746
+ .join('-')
747
+ .replace(/[^a-z0-9-]+/g, '-')
748
+ .replace(/^-+|-+$/g, '');
749
+ const requestId = `legacy-volume-${stablePart}`;
750
+ if (!stablePart || !storageRequestIdPattern.test(requestId)) {
751
+ throw new Error(
752
+ `Invalid ${labelArg} volumes[${indexArg}]: cannot derive a stable request id; declare a shorter lowercase name`,
753
+ );
754
+ }
755
+ return requestId;
756
+ }
757
+
758
+ function validateAllFilesystemMountsAndSecretTargets(
759
+ requestsArg: interfaces.appstore.TAppStoreStorageRequest[],
760
+ configArg: TAppStoreVersionConfig,
761
+ labelArg: string,
762
+ ): void {
763
+ const mountPaths: string[] = [];
764
+ const objectSecretPaths: string[] = [];
765
+ for (const requestArg of requestsArg) {
766
+ if (requestArg.kind === 'filesystem') {
767
+ assertNoPathCollision(requestArg.mountPath, mountPaths, `${labelArg} request '${requestArg.id}'`);
768
+ mountPaths.push(requestArg.mountPath);
769
+ } else if (requestArg.delivery.type === 'secretFile') {
770
+ objectSecretPaths.push(requestArg.delivery.targetPath);
771
+ }
772
+ }
773
+ for (const secretPathArg of objectSecretPaths) {
774
+ if (mountPaths.some((mountPathArg) => pathsOverlap(secretPathArg, mountPathArg))) {
775
+ throw new Error(`Invalid ${labelArg}: object-storage secret target '${secretPathArg}' overlaps a filesystem mount`);
776
+ }
777
+ }
778
+ const secretFileErrors = interfaces.data.validateServiceSecretFiles(
779
+ configArg.secretFiles,
780
+ mountPaths,
781
+ );
782
+ if (secretFileErrors.length > 0) {
783
+ throw new Error(`Invalid ${labelArg}: ${secretFileErrors.join('; ')}`);
784
+ }
785
+ }
786
+
787
+ function assertNoPathCollision(pathArg: string, pathsArg: string[], labelArg: string): void {
788
+ const collision = pathsArg.find((existingPathArg) => pathsOverlap(pathArg, existingPathArg));
789
+ if (collision) {
790
+ throw new Error(`Invalid ${labelArg}: path '${pathArg}' overlaps '${collision}'`);
791
+ }
792
+ }
793
+
794
+ function pathsOverlap(leftArg: string, rightArg: string): boolean {
795
+ const left = interfaces.data.normalizeServiceAbsolutePath(leftArg);
796
+ const right = interfaces.data.normalizeServiceAbsolutePath(rightArg);
797
+ if (!left || !right) return false;
798
+ if (left === '/' || right === '/') return true;
799
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
800
+ }
801
+
802
+ function assertPlainRecord(valueArg: unknown, labelArg: string): TPlainRecord {
803
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
804
+ throw new Error(`Invalid ${labelArg}: expected an object`);
805
+ }
806
+ return valueArg as TPlainRecord;
807
+ }
808
+
809
+ function assertExactKeys(
810
+ recordArg: TPlainRecord,
811
+ allowedKeysArg: readonly string[],
812
+ labelArg: string,
813
+ ): void {
814
+ const allowedKeys = new Set(allowedKeysArg);
815
+ const unknownKeys = Object.keys(recordArg).filter((keyArg) => !allowedKeys.has(keyArg));
816
+ if (unknownKeys.length > 0) {
817
+ throw new Error(`Invalid ${labelArg}: unknown field(s) ${unknownKeys.sort().join(', ')}`);
818
+ }
819
+ }
820
+
821
+ function assertOptionalMember(
822
+ valueArg: unknown,
823
+ allowedValuesArg: readonly string[],
824
+ labelArg: string,
825
+ optionalArg = true,
826
+ ): void {
827
+ if (valueArg === undefined && optionalArg) return;
828
+ if (typeof valueArg !== 'string' || !allowedValuesArg.includes(valueArg)) {
829
+ throw new Error(`Invalid ${labelArg}: expected one of ${allowedValuesArg.join(', ')}`);
830
+ }
831
+ }