@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.
@@ -9,6 +9,37 @@ export type TAppStoreDockerImageSource = interfaces.appstore.IAppStoreDockerImag
9
9
  export type TAppStoreRepoManifestSource = interfaces.appstore.IAppStoreRepoManifestSource;
10
10
  export type TAppStoreResolvedSource = interfaces.appstore.IAppStoreResolvedSource;
11
11
  export type TServezoneAppStoreManifest = interfaces.appstore.IServezoneAppStoreManifest;
12
+ export type TAppStoreStorageRequest = interfaces.appstore.TAppStoreStorageRequest;
13
+ export type TAppStoreStorageClass = interfaces.appstore.IAppStoreStorageClass;
14
+ export type TAppStoreStorageFeatureId = interfaces.appstore.TAppStoreStorageFeatureId;
15
+ export type TAppStoreStorageRequestOrigin = 'explicit' | 'legacyVolume' | 'legacyS3';
16
+ export interface IAppStoreStorageLegacyCompatibility {
17
+ /**
18
+ * Downstream adapters preserve the existing S3/AWS environment aliases for
19
+ * this request. New named bindings use their explicit delivery declaration.
20
+ */
21
+ objectStorage?: {
22
+ requestId: 'legacy-s3';
23
+ deliveryProfile: 'servezone-platform-s3-v1';
24
+ /** Legacy binding values replace same-key template env values. */
25
+ environmentOwnership: 'bindingOverridesTemplate';
26
+ };
27
+ }
28
+ export interface INormalizedAppStoreStorageConfig {
29
+ schemaVersion: 1;
30
+ storageClasses: Record<string, TAppStoreStorageClass>;
31
+ storageRequests: TAppStoreStorageRequest[];
32
+ requestOrigins: Record<string, TAppStoreStorageRequestOrigin>;
33
+ requiredFeatureIds: TAppStoreStorageFeatureId[];
34
+ legacyCompatibility?: IAppStoreStorageLegacyCompatibility;
35
+ }
36
+ export interface IAppStoreStorageNormalizationOptions {
37
+ /**
38
+ * Portable storage features advertised by the fulfillment adapter.
39
+ * Explicit storage requests fail closed when any required feature is absent.
40
+ */
41
+ supportedFeatures: Iterable<string>;
42
+ }
12
43
  export interface IAppStoreResolverOptions {
13
44
  source?: TAppStoreSourceMode;
14
45
  baseUrl?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/appstore",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "description": "serve.zone App Store metadata, parser, and resolver client.",
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
  "author": "Task Venture Capital GmbH",
11
11
  "license": "MIT",
12
12
  "dependencies": {
13
- "@serve.zone/interfaces": "^18.1.0"
13
+ "@serve.zone/interfaces": "18.1.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@git.zone/tsbuild": "^4.4.2",
package/readme.md CHANGED
@@ -57,7 +57,7 @@ Some templates ask Onebox to provision local platform services and inject connec
57
57
 
58
58
  | App ID | Requirements | Notes |
59
59
  | --- | --- | --- |
60
- | `cloudly` | MongoDB, S3 | Requires Onebox `2.2.0` or newer. Uses provisioned MongoDB and MinIO/S3 values for Cloudly state and registry storage, and reports initial admin credentials through hosted-app lifecycle. |
60
+ | `cloudly` | MongoDB, S3 | Requires Onebox `2.2.0` or newer. Uses provisioned MongoDB and managed S3-compatible storage for Cloudly state and registry storage, and reports initial admin credentials through hosted-app lifecycle. |
61
61
  | `ghost` | MariaDB | Uses MySQL-style `database__connection__*` environment variables. |
62
62
  | `gitea` | MariaDB | Uses Gitea database environment variables with `${MARIADB_*}` placeholders. |
63
63
  | `nextcloud` | MariaDB, Valkey | Uses MySQL-compatible database variables. Valkey is declared as a platform requirement. |
@@ -94,6 +94,144 @@ Version configs use a small, pragmatic schema understood by serve.zone runtimes:
94
94
  }
95
95
  ```
96
96
 
97
+ `platformRequirements.s3: true` and `volumes` are legacy inputs. The resolver
98
+ normalizes them deterministically for existing templates, but new templates use
99
+ portable, template-local `storageClasses` and stable named `storageRequests`.
100
+ Legacy `platformRequirements.s3: true` cannot coexist with an explicit
101
+ `objectStorage` request. Legacy volume objects may retain `name`, `mountPath`,
102
+ `readOnly`, and `backup`; `source`, `driver`, and `options` fail validation
103
+ because they cannot be normalized portably.
104
+
105
+ ## Portable Storage Requests
106
+
107
+ Filesystem mounts and managed object-storage resources are different request
108
+ kinds in the same portable contract. An app can request both, including more
109
+ than one isolated object-storage binding:
110
+
111
+ ```jsonc
112
+ {
113
+ "storageClasses": {
114
+ "databaseFast": {
115
+ "kind": "filesystem",
116
+ "purpose": "database",
117
+ "required": {
118
+ "performanceTier": "highIops",
119
+ "durability": "persistent",
120
+ "hardQuota": true,
121
+ "snapshots": "native",
122
+ "backup": true,
123
+ "encryptedInTransit": true
124
+ }
125
+ },
126
+ "backupCapacity": {
127
+ "kind": "objectStorage",
128
+ "purpose": "backup",
129
+ "required": {
130
+ "performanceTier": "capacity",
131
+ "durability": "persistent",
132
+ "hardQuota": true,
133
+ "backup": true,
134
+ "encryptedInTransit": true
135
+ }
136
+ }
137
+ },
138
+ "storageRequests": [
139
+ {
140
+ "id": "database-data",
141
+ "kind": "filesystem",
142
+ "storageClass": "databaseFast",
143
+ "mountPath": "/var/lib/example",
144
+ "accessMode": "ReadWriteOnce",
145
+ "capacity": { "request": "20GiB", "limit": "40GiB" },
146
+ "reclaimPolicy": "retain",
147
+ "protection": {
148
+ "backup": "required",
149
+ "snapshots": "native",
150
+ "consistency": "crashConsistent"
151
+ }
152
+ },
153
+ {
154
+ "id": "backup-archive",
155
+ "kind": "objectStorage",
156
+ "storageClass": "backupCapacity",
157
+ "accessMode": "readWrite",
158
+ "capacity": { "request": "100GiB", "limit": "1TiB" },
159
+ "reclaimPolicy": "retain",
160
+ "delivery": {
161
+ "type": "secretFile",
162
+ "targetPath": "/run/secrets/backup-archive.json",
163
+ "format": "servezone-object-storage-v1"
164
+ },
165
+ "protection": {
166
+ "backup": "required",
167
+ "versioning": "required",
168
+ "retentionDays": 30
169
+ }
170
+ },
171
+ {
172
+ "id": "registry-capacity",
173
+ "kind": "objectStorage",
174
+ "storageClass": "backupCapacity",
175
+ "accessMode": "readWrite",
176
+ "reclaimPolicy": "retain",
177
+ "delivery": {
178
+ "type": "environment",
179
+ "keys": {
180
+ "endpoint": "REGISTRY_S3_ENDPOINT",
181
+ "bucket": "REGISTRY_S3_BUCKET",
182
+ "region": "REGISTRY_S3_REGION",
183
+ "accessKeyId": "REGISTRY_S3_ACCESS_KEY_ID",
184
+ "secretAccessKey": "REGISTRY_S3_SECRET_ACCESS_KEY"
185
+ }
186
+ }
187
+ }
188
+ ],
189
+ "requiresFeatures": [
190
+ "storage.bindings.v1",
191
+ "storage.filesystem.v1",
192
+ "storage.object-storage.v1",
193
+ "storage.object-storage.secret-file.v1"
194
+ ]
195
+ }
196
+ ```
197
+
198
+ Capacity values are positive integers followed by `KiB`, `MiB`, `GiB`, or
199
+ `TiB`. A `limit` requires `storageClass.required.hardQuota: true`. For explicit
200
+ requests, delivery environment keys and secret-file paths must be unique across
201
+ the app config; provider credential values never belong in an App Store manifest.
202
+
203
+ The full version config and every storage object are exact-key validated.
204
+ Classes express portable policy only. Provider addresses, protocols,
205
+ identities, host paths, drivers, and driver options are rejected instead of
206
+ being passed through. Unknown `storage.*` feature IDs also fail closed. Request
207
+ IDs are stable across upgrades, restores, and migrations.
208
+
209
+ Before provisioning, a runtime must advertise its supported feature IDs and
210
+ call the resolver's fail-closed normalization entry point:
211
+
212
+ ```ts
213
+ const normalizedStorage = resolver.normalizeStorageConfig(config, {
214
+ supportedFeatures: capabilityAdvertisement.featureIds,
215
+ });
216
+ ```
217
+
218
+ The package also exports the pure `validateAppStoreStorageConfig()` and
219
+ `normalizeAppStoreStorageConfig()` functions, `parseStorageCapacityQuantity()`,
220
+ and the normalized storage handoff types. Structural validation is useful for
221
+ catalog ingestion; only normalization with a target's `supportedFeatures` is a
222
+ valid pre-fulfillment gate.
223
+
224
+ The result contains canonical `storageClasses`, `storageRequests`,
225
+ `requestOrigins`, and `requiredFeatureIds`. `requestOrigins` marks explicit,
226
+ legacy-volume, and legacy-S3 requests so fulfillment adapters can preserve old
227
+ delivery behavior without weakening the new contract. Legacy S3 normalization
228
+ uses the versioned `servezone-platform-s3-v1` delivery profile; adapters keep
229
+ the established S3/AWS environment aliases for that profile. Those
230
+ binding-provided values deterministically replace same-key legacy template
231
+ environment values. Legacy inputs do not claim new feature support, while every
232
+ explicit request fails before provisioning if a derived storage feature is
233
+ unavailable.
234
+
97
235
  Environment variable keys must start with a letter or underscore and may contain letters, numbers, and underscores. Mixed-case keys are supported because some upstream images, including Ghost and Gitea, require lowercase nested configuration segments such as `database__connection__host` or `GITEA__database__HOST`.
98
236
 
99
237
  Linked app manifests use the same runtime schema and let the upstream app repo own its install and upgrade metadata:
@@ -150,6 +288,8 @@ Only include keys that are actually needed by a template. For example, `nginx` i
150
288
  - Prefer package-published App Store data for runtime consumers. Use `source.type=repoManifest` only when the linked manifest is intentionally reachable from the runtime environment, or pre-resolve it before publishing an App Store package.
151
289
  - Prefer explicit environment variable descriptions because Onebox surfaces them to users during installation.
152
290
  - Use platform placeholders such as `${MARIADB_HOST}`, `${MARIADB_PORT}`, `${MARIADB_DATABASE}`, `${MARIADB_USER}`, `${MARIADB_PASSWORD}`, `${MONGODB_URI}`, `${S3_BUCKET}`, `${S3_ACCESS_KEY}`, and `${S3_SECRET_KEY}` only when the matching platform service is declared.
291
+ - Use named `objectStorage` requests instead of `platformRequirements.s3` in new templates, with a distinct delivery destination for every binding.
292
+ - Use named `filesystem` requests instead of `volumes` in new templates. Never put provider, host-mount, protocol, identity, driver, or driver-option details in portable manifests.
153
293
  - Keep template images boring and operationally safe unless there is a reason to pin a more specific upstream tag.
154
294
  - Validate non-trivial templates by installing them through Onebox instead of only checking JSON syntax.
155
295
 
@@ -1,9 +1,15 @@
1
1
  import * as interfaces from '@serve.zone/interfaces';
2
2
  import packageJson from '../package.json' with { type: 'json' };
3
3
  import { appStorePackageData } from './data.generated.js';
4
+ import {
5
+ normalizeAppStoreStorageConfig,
6
+ validateAppStoreStorageConfig,
7
+ } from './storage.js';
4
8
  import type {
5
9
  IAppStoreResolverOptions,
6
10
  IAppStoreSourceInfo,
11
+ IAppStoreStorageNormalizationOptions,
12
+ INormalizedAppStoreStorageConfig,
7
13
  IParsedDockerImageReference,
8
14
  IResolvedAppStoreApp,
9
15
  TAppStoreApp,
@@ -252,14 +258,7 @@ export class AppStoreResolver {
252
258
  }
253
259
  }
254
260
 
255
- const normalizedVolumes = this.normalizeVolumes(configArg.volumes);
256
- const secretFileErrors = interfaces.data.validateServiceSecretFiles(
257
- configArg.secretFiles,
258
- normalizedVolumes.map((volumeArg) => volumeArg.mountPath),
259
- );
260
- if (secretFileErrors.length > 0) {
261
- throw new Error(`Invalid ${labelArg}: ${secretFileErrors.join('; ')}`);
262
- }
261
+ validateAppStoreStorageConfig(configArg, labelArg);
263
262
  for (const secretFile of configArg.secretFiles || []) {
264
263
  if (!secretEnvVarKeys.has(secretFile.sourceKey)) {
265
264
  throw new Error(
@@ -274,6 +273,20 @@ export class AppStoreResolver {
274
273
  this.validatePublishedPorts(configArg.publishedPorts || [], labelArg);
275
274
  }
276
275
 
276
+ /**
277
+ * Produces the canonical storage handoff only after target feature
278
+ * negotiation. Fulfillment adapters should use this method before creating
279
+ * or mutating any storage resource.
280
+ */
281
+ public normalizeStorageConfig(
282
+ configArg: TAppStoreVersionConfig,
283
+ optionsArg: IAppStoreStorageNormalizationOptions,
284
+ labelArg = 'appstore config',
285
+ ): INormalizedAppStoreStorageConfig {
286
+ this.validateAppStoreVersionConfig(configArg, labelArg);
287
+ return normalizeAppStoreStorageConfig(configArg, optionsArg, labelArg);
288
+ }
289
+
277
290
  public normalizeVolumes(
278
291
  volumesArg: TAppStoreVersionConfig['volumes'] = [],
279
292
  ): interfaces.appstore.IAppStoreVolume[] {
@@ -569,14 +582,31 @@ export class AppStoreResolver {
569
582
  }
570
583
 
571
584
  private validateVolume(volumeArg: interfaces.appstore.IAppStoreVolume, labelArg: string): void {
572
- if (!volumeArg.mountPath || !volumeArg.mountPath.startsWith('/')) {
573
- throw new Error(`Invalid ${labelArg}: mountPath must be an absolute path`);
574
- }
575
- if (volumeArg.mountPath.includes(':')) {
576
- throw new Error(`Invalid ${labelArg}: mountPath must not contain ':'`);
577
- }
578
- if ((volumeArg.source || volumeArg.name)?.includes(':')) {
579
- throw new Error(`Invalid ${labelArg}: source/name must not contain ':'`);
585
+ const unknownKeys = Object.keys(volumeArg).filter((keyArg) => ![
586
+ 'name',
587
+ 'source',
588
+ 'mountPath',
589
+ 'driver',
590
+ 'readOnly',
591
+ 'backup',
592
+ 'options',
593
+ ].includes(keyArg));
594
+ if (unknownKeys.length > 0) {
595
+ throw new Error(`Invalid ${labelArg}: unknown field(s) ${unknownKeys.sort().join(', ')}`);
596
+ }
597
+ if (!volumeArg.mountPath
598
+ || interfaces.data.normalizeServiceAbsolutePath(volumeArg.mountPath) !== volumeArg.mountPath
599
+ || volumeArg.mountPath === '/') {
600
+ throw new Error(`Invalid ${labelArg}: mountPath must be a canonical non-root absolute path`);
601
+ }
602
+ if (volumeArg.source !== undefined
603
+ || volumeArg.driver !== undefined
604
+ || volumeArg.options !== undefined) {
605
+ throw new Error(`Invalid ${labelArg}: physical source, driver, and options fields are not portable`);
606
+ }
607
+ if (volumeArg.name !== undefined
608
+ && !/^[a-z][a-z0-9-]{0,62}$/.test(volumeArg.name)) {
609
+ throw new Error(`Invalid ${labelArg}: name must be a stable lowercase identifier`);
580
610
  }
581
611
  }
582
612
 
@@ -1,2 +1,3 @@
1
1
  export * from './classes.appstoreresolver.js';
2
+ export * from './storage.js';
2
3
  export * from './types.js';