@serve.zone/appstore 1.3.0 → 1.4.1

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,11 +9,43 @@ 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;
15
46
  fetch?: TAppStoreFetch;
16
47
  resolveDockerDigests?: boolean;
48
+ requestTimeoutMs?: number;
17
49
  now?: () => Date;
18
50
  }
19
51
  export interface IAppStorePackageData {
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.1",
4
4
  "private": false,
5
5
  "description": "serve.zone App Store metadata, parser, and resolver client.",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  "author": "Task Venture Capital GmbH",
11
11
  "license": "MIT",
12
12
  "dependencies": {
13
- "@serve.zone/interfaces": "^18.1.0"
13
+ "@push.rocks/smartversion": "^3.1.0",
14
+ "@serve.zone/interfaces": "18.1.0"
14
15
  },
15
16
  "devDependencies": {
16
17
  "@git.zone/tsbuild": "^4.4.2",
package/readme.md CHANGED
@@ -26,7 +26,7 @@ Changes are reviewed by reading the JSON data, running the client tests, and tes
26
26
 
27
27
  ## Current App Store
28
28
 
29
- The App Store currently lists 20 app templates.
29
+ The App Store currently lists 21 app templates.
30
30
 
31
31
  | App ID | Name | App Store category | Image | Port |
32
32
  | --- | --- | --- | --- | --- |
@@ -41,6 +41,7 @@ The App Store currently lists 20 app templates.
41
41
  | `n8n` | N8N | Automation | `n8nio/n8n:latest` | `5678` |
42
42
  | `nextcloud` | Nextcloud | Storage | `nextcloud:latest` | `80` |
43
43
  | `nginx` | Nginx | Web Server | `nginx:alpine` | `80` |
44
+ | `openbao` | OpenBao | Security | `openbao/openbao:2.6.0` | `8200` |
44
45
  | `plausible` | Plausible Analytics | Analytics | `plausible/analytics:latest` | `8000` |
45
46
  | `portainer` | Portainer | Dev Tools | `portainer/portainer-ce:latest` | `9000` |
46
47
  | `postgres` | PostgreSQL | Database | `postgres:16-alpine` | `5432` |
@@ -57,7 +58,7 @@ Some templates ask Onebox to provision local platform services and inject connec
57
58
 
58
59
  | App ID | Requirements | Notes |
59
60
  | --- | --- | --- |
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. |
61
+ | `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
62
  | `ghost` | MariaDB | Uses MySQL-style `database__connection__*` environment variables. |
62
63
  | `gitea` | MariaDB | Uses Gitea database environment variables with `${MARIADB_*}` placeholders. |
63
64
  | `nextcloud` | MariaDB, Valkey | Uses MySQL-compatible database variables. Valkey is declared as a platform requirement. |
@@ -94,6 +95,144 @@ Version configs use a small, pragmatic schema understood by serve.zone runtimes:
94
95
  }
95
96
  ```
96
97
 
98
+ `platformRequirements.s3: true` and `volumes` are legacy inputs. The resolver
99
+ normalizes them deterministically for existing templates, but new templates use
100
+ portable, template-local `storageClasses` and stable named `storageRequests`.
101
+ Legacy `platformRequirements.s3: true` cannot coexist with an explicit
102
+ `objectStorage` request. Legacy volume objects may retain `name`, `mountPath`,
103
+ `readOnly`, and `backup`; `source`, `driver`, and `options` fail validation
104
+ because they cannot be normalized portably.
105
+
106
+ ## Portable Storage Requests
107
+
108
+ Filesystem mounts and managed object-storage resources are different request
109
+ kinds in the same portable contract. An app can request both, including more
110
+ than one isolated object-storage binding:
111
+
112
+ ```jsonc
113
+ {
114
+ "storageClasses": {
115
+ "databaseFast": {
116
+ "kind": "filesystem",
117
+ "purpose": "database",
118
+ "required": {
119
+ "performanceTier": "highIops",
120
+ "durability": "persistent",
121
+ "hardQuota": true,
122
+ "snapshots": "native",
123
+ "backup": true,
124
+ "encryptedInTransit": true
125
+ }
126
+ },
127
+ "backupCapacity": {
128
+ "kind": "objectStorage",
129
+ "purpose": "backup",
130
+ "required": {
131
+ "performanceTier": "capacity",
132
+ "durability": "persistent",
133
+ "hardQuota": true,
134
+ "backup": true,
135
+ "encryptedInTransit": true
136
+ }
137
+ }
138
+ },
139
+ "storageRequests": [
140
+ {
141
+ "id": "database-data",
142
+ "kind": "filesystem",
143
+ "storageClass": "databaseFast",
144
+ "mountPath": "/var/lib/example",
145
+ "accessMode": "ReadWriteOnce",
146
+ "capacity": { "request": "20GiB", "limit": "40GiB" },
147
+ "reclaimPolicy": "retain",
148
+ "protection": {
149
+ "backup": "required",
150
+ "snapshots": "native",
151
+ "consistency": "crashConsistent"
152
+ }
153
+ },
154
+ {
155
+ "id": "backup-archive",
156
+ "kind": "objectStorage",
157
+ "storageClass": "backupCapacity",
158
+ "accessMode": "readWrite",
159
+ "capacity": { "request": "100GiB", "limit": "1TiB" },
160
+ "reclaimPolicy": "retain",
161
+ "delivery": {
162
+ "type": "secretFile",
163
+ "targetPath": "/run/secrets/backup-archive.json",
164
+ "format": "servezone-object-storage-v1"
165
+ },
166
+ "protection": {
167
+ "backup": "required",
168
+ "versioning": "required",
169
+ "retentionDays": 30
170
+ }
171
+ },
172
+ {
173
+ "id": "registry-capacity",
174
+ "kind": "objectStorage",
175
+ "storageClass": "backupCapacity",
176
+ "accessMode": "readWrite",
177
+ "reclaimPolicy": "retain",
178
+ "delivery": {
179
+ "type": "environment",
180
+ "keys": {
181
+ "endpoint": "REGISTRY_S3_ENDPOINT",
182
+ "bucket": "REGISTRY_S3_BUCKET",
183
+ "region": "REGISTRY_S3_REGION",
184
+ "accessKeyId": "REGISTRY_S3_ACCESS_KEY_ID",
185
+ "secretAccessKey": "REGISTRY_S3_SECRET_ACCESS_KEY"
186
+ }
187
+ }
188
+ }
189
+ ],
190
+ "requiresFeatures": [
191
+ "storage.bindings.v1",
192
+ "storage.filesystem.v1",
193
+ "storage.object-storage.v1",
194
+ "storage.object-storage.secret-file.v1"
195
+ ]
196
+ }
197
+ ```
198
+
199
+ Capacity values are positive integers followed by `KiB`, `MiB`, `GiB`, or
200
+ `TiB`. A `limit` requires `storageClass.required.hardQuota: true`. For explicit
201
+ requests, delivery environment keys and secret-file paths must be unique across
202
+ the app config; provider credential values never belong in an App Store manifest.
203
+
204
+ The full version config and every storage object are exact-key validated.
205
+ Classes express portable policy only. Provider addresses, protocols,
206
+ identities, host paths, drivers, and driver options are rejected instead of
207
+ being passed through. Unknown `storage.*` feature IDs also fail closed. Request
208
+ IDs are stable across upgrades, restores, and migrations.
209
+
210
+ Before provisioning, a runtime must advertise its supported feature IDs and
211
+ call the resolver's fail-closed normalization entry point:
212
+
213
+ ```ts
214
+ const normalizedStorage = resolver.normalizeStorageConfig(config, {
215
+ supportedFeatures: capabilityAdvertisement.featureIds,
216
+ });
217
+ ```
218
+
219
+ The package also exports the pure `validateAppStoreStorageConfig()` and
220
+ `normalizeAppStoreStorageConfig()` functions, `parseStorageCapacityQuantity()`,
221
+ and the normalized storage handoff types. Structural validation is useful for
222
+ catalog ingestion; only normalization with a target's `supportedFeatures` is a
223
+ valid pre-fulfillment gate.
224
+
225
+ The result contains canonical `storageClasses`, `storageRequests`,
226
+ `requestOrigins`, and `requiredFeatureIds`. `requestOrigins` marks explicit,
227
+ legacy-volume, and legacy-S3 requests so fulfillment adapters can preserve old
228
+ delivery behavior without weakening the new contract. Legacy S3 normalization
229
+ uses the versioned `servezone-platform-s3-v1` delivery profile; adapters keep
230
+ the established S3/AWS environment aliases for that profile. Those
231
+ binding-provided values deterministically replace same-key legacy template
232
+ environment values. Legacy inputs do not claim new feature support, while every
233
+ explicit request fails before provisioning if a derived storage feature is
234
+ unavailable.
235
+
97
236
  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
237
 
99
238
  Linked app manifests use the same runtime schema and let the upstream app repo own its install and upgrade metadata:
@@ -133,7 +272,9 @@ const cloudly = await resolver.getAppMeta('cloudly');
133
272
  const config = await resolver.getAppVersionConfig('cloudly', cloudly.latestVersion);
134
273
  ```
135
274
 
136
- The resolver defaults to the App Store data bundled in the published `@serve.zone/appstore` package. Tests and explicit remote consumers can inject a custom `fetch` implementation and base URL.
275
+ The resolver defaults to the App Store data bundled in the published `@serve.zone/appstore` package. Tests and explicit remote consumers can inject a custom `fetch` implementation and base URL. Remote operations default to a 30-second timeout, configurable with a positive-integer `requestTimeoutMs`.
276
+
277
+ Catalog resolution fails closed when index and app metadata IDs or latest versions disagree, when metadata latest is absent from its version list, or when an all-semver list does not select its highest semantic value. Repo manifests with multiple non-semver versions require explicit latest or channel intent; array position never determines latest.
137
278
 
138
279
  Only include keys that are actually needed by a template. For example, `nginx` is currently just:
139
280
 
@@ -150,6 +291,8 @@ Only include keys that are actually needed by a template. For example, `nginx` i
150
291
  - 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
292
  - Prefer explicit environment variable descriptions because Onebox surfaces them to users during installation.
152
293
  - 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.
294
+ - Use named `objectStorage` requests instead of `platformRequirements.s3` in new templates, with a distinct delivery destination for every binding.
295
+ - 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
296
  - Keep template images boring and operationally safe unless there is a reason to pin a more specific upstream tag.
154
297
  - Validate non-trivial templates by installing them through Onebox instead of only checking JSON syntax.
155
298
 
@@ -1,9 +1,16 @@
1
1
  import * as interfaces from '@serve.zone/interfaces';
2
+ import { SmartVersion } from '@push.rocks/smartversion';
2
3
  import packageJson from '../package.json' with { type: 'json' };
3
4
  import { appStorePackageData } from './data.generated.js';
5
+ import {
6
+ normalizeAppStoreStorageConfig,
7
+ validateAppStoreStorageConfig,
8
+ } from './storage.js';
4
9
  import type {
5
10
  IAppStoreResolverOptions,
6
11
  IAppStoreSourceInfo,
12
+ IAppStoreStorageNormalizationOptions,
13
+ INormalizedAppStoreStorageConfig,
7
14
  IParsedDockerImageReference,
8
15
  IResolvedAppStoreApp,
9
16
  TAppStoreApp,
@@ -28,8 +35,10 @@ export class AppStoreResolver {
28
35
  private readonly fetchRef: typeof fetch;
29
36
  private readonly source: TAppStoreSourceMode;
30
37
  private readonly resolveDockerDigests: boolean;
38
+ private readonly requestTimeoutMs: number;
31
39
  private readonly now: () => Date;
32
40
  private appStoreCache: TAppStoreIndex | null = null;
41
+ private inlineAppMetaCache = new Map<string, interfaces.appstore.IAppStoreAppMeta>();
33
42
  private sourceAppCache = new Map<string, IResolvedAppStoreApp>();
34
43
 
35
44
  constructor(optionsArg: IAppStoreResolverOptions = {}) {
@@ -37,6 +46,10 @@ export class AppStoreResolver {
37
46
  this.baseUrl = optionsArg.baseUrl || appStoreDefaultRemoteBaseUrl;
38
47
  this.fetchRef = optionsArg.fetch || fetch;
39
48
  this.resolveDockerDigests = optionsArg.resolveDockerDigests ?? true;
49
+ this.requestTimeoutMs = optionsArg.requestTimeoutMs ?? 30_000;
50
+ if (!Number.isSafeInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 1) {
51
+ throw new Error('App Store requestTimeoutMs must be a positive integer');
52
+ }
40
53
  this.now = optionsArg.now || (() => new Date());
41
54
  }
42
55
 
@@ -64,13 +77,20 @@ export class AppStoreResolver {
64
77
 
65
78
  public async getAppMeta(appIdArg: string): Promise<interfaces.appstore.IAppStoreAppMeta> {
66
79
  const app = await this.getAppStoreApp(appIdArg);
80
+ if (!app) {
81
+ throw new Error(`App Store app '${appIdArg}' is not present in the resolved index`);
82
+ }
67
83
  if (app?.source?.type === 'repoManifest') {
68
84
  return (await this.resolveRepoManifestSource(app.source, app)).appMeta;
69
85
  }
70
86
  if (app?.source?.type === 'dockerImage') {
71
87
  return this.createAppMetaFromAppStoreApp(app);
72
88
  }
73
- return await this.fetchJson(`apps/${appIdArg}/app.json`) as interfaces.appstore.IAppStoreAppMeta;
89
+ const appMeta = this.inlineAppMetaCache.get(appIdArg);
90
+ if (!appMeta) {
91
+ throw new Error(`App Store metadata for '${appIdArg}' is unavailable`);
92
+ }
93
+ return structuredClone(appMeta);
74
94
  }
75
95
 
76
96
  public async getAppVersionConfig(
@@ -116,6 +136,7 @@ export class AppStoreResolver {
116
136
  public async resolveAppStore(appStoreArg: unknown): Promise<TAppStoreIndex> {
117
137
  const appStore = parseAppStoreIndex(appStoreArg);
118
138
  this.sourceAppCache.clear();
139
+ this.inlineAppMetaCache.clear();
119
140
  const apps: TAppStoreApp[] = [];
120
141
 
121
142
  for (const app of appStore.apps) {
@@ -252,14 +273,7 @@ export class AppStoreResolver {
252
273
  }
253
274
  }
254
275
 
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
- }
276
+ validateAppStoreStorageConfig(configArg, labelArg);
263
277
  for (const secretFile of configArg.secretFiles || []) {
264
278
  if (!secretEnvVarKeys.has(secretFile.sourceKey)) {
265
279
  throw new Error(
@@ -274,6 +288,20 @@ export class AppStoreResolver {
274
288
  this.validatePublishedPorts(configArg.publishedPorts || [], labelArg);
275
289
  }
276
290
 
291
+ /**
292
+ * Produces the canonical storage handoff only after target feature
293
+ * negotiation. Fulfillment adapters should use this method before creating
294
+ * or mutating any storage resource.
295
+ */
296
+ public normalizeStorageConfig(
297
+ configArg: TAppStoreVersionConfig,
298
+ optionsArg: IAppStoreStorageNormalizationOptions,
299
+ labelArg = 'appstore config',
300
+ ): INormalizedAppStoreStorageConfig {
301
+ this.validateAppStoreVersionConfig(configArg, labelArg);
302
+ return normalizeAppStoreStorageConfig(configArg, optionsArg, labelArg);
303
+ }
304
+
277
305
  public normalizeVolumes(
278
306
  volumesArg: TAppStoreVersionConfig['volumes'] = [],
279
307
  ): interfaces.appstore.IAppStoreVolume[] {
@@ -330,7 +358,10 @@ export class AppStoreResolver {
330
358
  };
331
359
  }
332
360
 
333
- return appArg;
361
+ const appMeta = await this.fetchJson(
362
+ `apps/${appArg.id}/app.json`,
363
+ ) as interfaces.appstore.IAppStoreAppMeta;
364
+ return this.validateInlineAppMetadata(appArg, appMeta);
334
365
  }
335
366
 
336
367
  private async resolveServezoneAppStoreManifest(
@@ -382,9 +413,17 @@ export class AppStoreResolver {
382
413
 
383
414
  const selectedChannel = manifestArg.policy?.defaultChannel || manifestArg.channel || 'stable';
384
415
  const channelVersion = manifestArg.channels?.[selectedChannel];
385
- const declaredLatestVersion = manifestArg.latestVersion || channelVersion || versions[versions.length - 1];
386
- const latestVersion = sourceVersionToResolvedVersion.get(declaredLatestVersion) || declaredLatestVersion;
387
416
  const uniqueVersions = uniqueStrings(versions);
417
+ const declaredLatestVersion = manifestArg.latestVersion
418
+ || channelVersion
419
+ || selectImplicitLatestVersion(uniqueVersions, `Appstore manifest '${manifestArg.app.id}'`);
420
+ const latestVersion = sourceVersionToResolvedVersion.get(declaredLatestVersion)
421
+ || declaredLatestVersion;
422
+ if (!configsByVersion.has(latestVersion)) {
423
+ throw new Error(
424
+ `Appstore manifest '${manifestArg.app.id}' latestVersion '${declaredLatestVersion}' has no runtime config`,
425
+ );
426
+ }
388
427
 
389
428
  const appStoreApp: TAppStoreApp = {
390
429
  id: manifestArg.app.id,
@@ -447,6 +486,50 @@ export class AppStoreResolver {
447
486
  return configArg;
448
487
  }
449
488
 
489
+ private validateInlineAppMetadata(
490
+ appArg: TAppStoreApp,
491
+ appMetaArg: interfaces.appstore.IAppStoreAppMeta,
492
+ ): TAppStoreApp {
493
+ if (appMetaArg.id !== appArg.id) {
494
+ throw new Error(
495
+ `App Store metadata ID '${appMetaArg.id}' does not match index app '${appArg.id}'`,
496
+ );
497
+ }
498
+ if (!appMetaArg.latestVersion) {
499
+ throw new Error(`App Store metadata for '${appArg.id}' has no latestVersion`);
500
+ }
501
+ const versions = uniqueStrings(
502
+ appMetaArg.versions?.length ? appMetaArg.versions : [appMetaArg.latestVersion],
503
+ );
504
+ if (!versions.includes(appMetaArg.latestVersion)) {
505
+ throw new Error(
506
+ `App Store metadata for '${appArg.id}' latestVersion '${appMetaArg.latestVersion}' is not declared in versions`,
507
+ );
508
+ }
509
+ const semverLatest = selectHighestSemanticVersion(versions);
510
+ if (semverLatest && semverLatest !== appMetaArg.latestVersion) {
511
+ throw new Error(
512
+ `App Store metadata for '${appArg.id}' declares latestVersion '${appMetaArg.latestVersion}' but highest semantic version is '${semverLatest}'`,
513
+ );
514
+ }
515
+ const resolvedLatestVersion = semverLatest || appMetaArg.latestVersion;
516
+ if (appArg.latestVersion !== resolvedLatestVersion) {
517
+ throw new Error(
518
+ `App Store index for '${appArg.id}' declares latestVersion '${appArg.latestVersion}' but app metadata resolves '${resolvedLatestVersion}'`,
519
+ );
520
+ }
521
+ this.inlineAppMetaCache.set(appArg.id, {
522
+ ...appMetaArg,
523
+ latestVersion: resolvedLatestVersion,
524
+ versions,
525
+ });
526
+ return {
527
+ ...appArg,
528
+ latestVersion: resolvedLatestVersion,
529
+ versions,
530
+ };
531
+ }
532
+
450
533
  private createAppMetaFromAppStoreApp(appArg: TAppStoreApp): interfaces.appstore.IAppStoreAppMeta {
451
534
  return {
452
535
  id: appArg.id,
@@ -467,6 +550,56 @@ export class AppStoreResolver {
467
550
  return appStore.apps.find((appArg) => appArg.id === appIdArg);
468
551
  }
469
552
 
553
+ private async withRemoteResponse<T>(
554
+ urlArg: string,
555
+ initArg: RequestInit,
556
+ consumeArg: (responseArg: Response) => Promise<T> | T,
557
+ ): Promise<T> {
558
+ const controller = new AbortController();
559
+ const upstreamSignal = initArg.signal;
560
+ const forwardAbort = (): void => {
561
+ controller.abort(upstreamSignal?.reason);
562
+ };
563
+ if (upstreamSignal?.aborted) {
564
+ forwardAbort();
565
+ } else {
566
+ upstreamSignal?.addEventListener('abort', forwardAbort, { once: true });
567
+ }
568
+ const timeout = setTimeout(() => {
569
+ controller.abort(new Error(`App Store request timed out after ${this.requestTimeoutMs}ms: ${urlArg}`));
570
+ }, this.requestTimeoutMs);
571
+ let response: Response | undefined;
572
+ try {
573
+ response = await this.fetchRef(urlArg, {
574
+ ...initArg,
575
+ signal: controller.signal,
576
+ });
577
+ return await consumeArg(response);
578
+ } catch (error) {
579
+ if (controller.signal.aborted && controller.signal.reason instanceof Error) {
580
+ throw controller.signal.reason;
581
+ }
582
+ throw error;
583
+ } finally {
584
+ clearTimeout(timeout);
585
+ upstreamSignal?.removeEventListener('abort', forwardAbort);
586
+ if (response?.body && !response.bodyUsed) {
587
+ void response.body.cancel().catch(() => {});
588
+ }
589
+ }
590
+ }
591
+
592
+ private async fetchRemoteHeaders(
593
+ urlArg: string,
594
+ initArg: RequestInit = {},
595
+ ): Promise<{ status: number; ok: boolean; headers: Headers }> {
596
+ return await this.withRemoteResponse(urlArg, initArg, (responseArg) => ({
597
+ status: responseArg.status,
598
+ ok: responseArg.ok,
599
+ headers: new Headers(responseArg.headers),
600
+ }));
601
+ }
602
+
470
603
  private async fetchJson(pathArg: string): Promise<unknown> {
471
604
  if (this.source === 'package') {
472
605
  const value = appStorePackageData.jsonFiles[pathArg];
@@ -477,11 +610,12 @@ export class AppStoreResolver {
477
610
  }
478
611
 
479
612
  const url = `${this.baseUrl}/${pathArg}`;
480
- const response = await this.fetchRef(url);
481
- if (!response.ok) {
482
- throw new Error(`HTTP ${response.status} for ${url}`);
483
- }
484
- return response.json();
613
+ return await this.withRemoteResponse(url, {}, async (responseArg) => {
614
+ if (!responseArg.ok) {
615
+ throw new Error(`HTTP ${responseArg.status} for ${url}`);
616
+ }
617
+ return await responseArg.json();
618
+ });
485
619
  }
486
620
 
487
621
  public async fetchText(pathArg: string): Promise<string> {
@@ -496,19 +630,21 @@ export class AppStoreResolver {
496
630
  }
497
631
 
498
632
  const url = `${this.baseUrl}/${pathArg}`;
499
- const response = await this.fetchRef(url);
500
- if (!response.ok) {
501
- throw new Error(`HTTP ${response.status} for ${url}`);
502
- }
503
- return response.text();
633
+ return await this.withRemoteResponse(url, {}, async (responseArg) => {
634
+ if (!responseArg.ok) {
635
+ throw new Error(`HTTP ${responseArg.status} for ${url}`);
636
+ }
637
+ return await responseArg.text();
638
+ });
504
639
  }
505
640
 
506
641
  private async fetchTextFromUrl(urlArg: string): Promise<string> {
507
- const response = await this.fetchRef(urlArg);
508
- if (!response.ok) {
509
- throw new Error(`HTTP ${response.status} for ${urlArg}`);
510
- }
511
- return response.text();
642
+ return await this.withRemoteResponse(urlArg, {}, async (responseArg) => {
643
+ if (!responseArg.ok) {
644
+ throw new Error(`HTTP ${responseArg.status} for ${urlArg}`);
645
+ }
646
+ return await responseArg.text();
647
+ });
512
648
  }
513
649
 
514
650
  private async fetchDockerManifestDigest(imageArg: IParsedDockerImageReference): Promise<string | null> {
@@ -522,18 +658,18 @@ export class AppStoreResolver {
522
658
  ].join(', '),
523
659
  });
524
660
 
525
- let response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers });
661
+ let response = await this.fetchRemoteHeaders(manifestUrl, { method: 'HEAD', headers });
526
662
  if (response.status === 401) {
527
663
  const authHeader = response.headers.get('www-authenticate');
528
664
  const token = authHeader ? await this.fetchDockerRegistryToken(authHeader, imageArg.repository) : null;
529
665
  if (token) {
530
666
  headers.set('Authorization', `Bearer ${token}`);
531
- response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers });
667
+ response = await this.fetchRemoteHeaders(manifestUrl, { method: 'HEAD', headers });
532
668
  }
533
669
  }
534
670
 
535
671
  if (!response.ok || !response.headers.get('docker-content-digest')) {
536
- response = await this.fetchRef(manifestUrl, { method: 'GET', headers });
672
+ response = await this.fetchRemoteHeaders(manifestUrl, { method: 'GET', headers });
537
673
  }
538
674
 
539
675
  if (!response.ok) {
@@ -562,21 +698,42 @@ export class AppStoreResolver {
562
698
  if (service) tokenUrl.searchParams.set('service', service);
563
699
  tokenUrl.searchParams.set('scope', scope);
564
700
 
565
- const response = await this.fetchRef(tokenUrl.toString());
566
- if (!response.ok) return null;
567
- const tokenResponse = await response.json() as { token?: string; access_token?: string };
568
- return tokenResponse.token || tokenResponse.access_token || null;
701
+ const tokenResponse = await this.withRemoteResponse<{
702
+ token?: string;
703
+ access_token?: string;
704
+ } | null>(tokenUrl.toString(), {}, async (responseArg) => {
705
+ if (!responseArg.ok) return null;
706
+ return await responseArg.json() as { token?: string; access_token?: string };
707
+ });
708
+ return tokenResponse?.token || tokenResponse?.access_token || null;
569
709
  }
570
710
 
571
711
  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 ':'`);
712
+ const unknownKeys = Object.keys(volumeArg).filter((keyArg) => ![
713
+ 'name',
714
+ 'source',
715
+ 'mountPath',
716
+ 'driver',
717
+ 'readOnly',
718
+ 'backup',
719
+ 'options',
720
+ ].includes(keyArg));
721
+ if (unknownKeys.length > 0) {
722
+ throw new Error(`Invalid ${labelArg}: unknown field(s) ${unknownKeys.sort().join(', ')}`);
723
+ }
724
+ if (!volumeArg.mountPath
725
+ || interfaces.data.normalizeServiceAbsolutePath(volumeArg.mountPath) !== volumeArg.mountPath
726
+ || volumeArg.mountPath === '/') {
727
+ throw new Error(`Invalid ${labelArg}: mountPath must be a canonical non-root absolute path`);
728
+ }
729
+ if (volumeArg.source !== undefined
730
+ || volumeArg.driver !== undefined
731
+ || volumeArg.options !== undefined) {
732
+ throw new Error(`Invalid ${labelArg}: physical source, driver, and options fields are not portable`);
733
+ }
734
+ if (volumeArg.name !== undefined
735
+ && !/^[a-z][a-z0-9-]{0,62}$/.test(volumeArg.name)) {
736
+ throw new Error(`Invalid ${labelArg}: name must be a stable lowercase identifier`);
580
737
  }
581
738
  }
582
739
 
@@ -724,6 +881,39 @@ async function createSha256Hex(inputArg: string): Promise<string> {
724
881
  .join('');
725
882
  }
726
883
 
884
+ function selectHighestSemanticVersion(versionsArg: string[]): string | null {
885
+ if (versionsArg.length === 0) return null;
886
+ const parsedVersions: SmartVersion[] = [];
887
+ for (const versionArg of versionsArg) {
888
+ try {
889
+ parsedVersions.push(new SmartVersion(versionArg));
890
+ } catch {
891
+ return null;
892
+ }
893
+ }
894
+ let highestIndex = 0;
895
+ for (let index = 1; index < parsedVersions.length; index++) {
896
+ if (parsedVersions[index].greaterThan(parsedVersions[highestIndex])) {
897
+ highestIndex = index;
898
+ }
899
+ }
900
+ return versionsArg[highestIndex];
901
+ }
902
+
903
+ function selectImplicitLatestVersion(versionsArg: string[], labelArg: string): string {
904
+ if (versionsArg.length === 0) {
905
+ throw new Error(`${labelArg} defines no versions`);
906
+ }
907
+ if (versionsArg.length === 1) return versionsArg[0];
908
+ const semverLatest = selectHighestSemanticVersion(versionsArg);
909
+ if (!semverLatest) {
910
+ throw new Error(
911
+ `${labelArg} must declare latestVersion or a channel when multiple non-semver versions exist`,
912
+ );
913
+ }
914
+ return semverLatest;
915
+ }
916
+
727
917
  function uniqueStrings(valuesArg: string[]): string[] {
728
918
  return Array.from(new Set(valuesArg.filter(Boolean)));
729
919
  }
@@ -1118,7 +1118,7 @@ export const appStorePackageData: IAppStorePackageData = {
1118
1118
  },
1119
1119
  "appstore.json": {
1120
1120
  "schemaVersion": 1,
1121
- "updatedAt": "2026-07-21T00:00:00Z",
1121
+ "updatedAt": "2026-07-25T00:00:00Z",
1122
1122
  "apps": [
1123
1123
  {
1124
1124
  "id": "nginx",
@@ -1326,7 +1326,7 @@ export const appStorePackageData: IAppStorePackageData = {
1326
1326
  "description": "Multi-node serve.zone control plane for clusters, workload services, domains, and deployments.",
1327
1327
  "category": "Dev Tools",
1328
1328
  "iconName": "server",
1329
- "latestVersion": "1.0.0",
1329
+ "latestVersion": "1.1.0",
1330
1330
  "tags": [
1331
1331
  "serve.zone",
1332
1332
  "control-plane",