@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.
- package/appstore.json +2 -2
- package/changelog.md +23 -0
- package/dist_ts_client/classes.appstoreresolver.d.ts +12 -1
- package/dist_ts_client/classes.appstoreresolver.js +196 -41
- package/dist_ts_client/data.generated.js +2 -2
- package/dist_ts_client/index.d.ts +1 -0
- package/dist_ts_client/index.js +2 -1
- package/dist_ts_client/storage.d.ts +13 -0
- package/dist_ts_client/storage.js +635 -0
- package/dist_ts_client/types.d.ts +32 -0
- package/package.json +3 -2
- package/readme.md +146 -3
- package/ts_client/classes.appstoreresolver.ts +232 -42
- package/ts_client/data.generated.ts +2 -2
- package/ts_client/index.ts +1 -0
- package/ts_client/readme.md +4 -1
- package/ts_client/storage.ts +831 -0
- package/ts_client/types.ts +39 -0
|
@@ -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
|
+
"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
|
-
"@
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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-
|
|
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.
|
|
1329
|
+
"latestVersion": "1.1.0",
|
|
1330
1330
|
"tags": [
|
|
1331
1331
|
"serve.zone",
|
|
1332
1332
|
"control-plane",
|