@serve.zone/appstore 1.4.0 → 1.4.2

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.
@@ -1,4 +1,5 @@
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';
4
5
  import {
@@ -34,8 +35,10 @@ export class AppStoreResolver {
34
35
  private readonly fetchRef: typeof fetch;
35
36
  private readonly source: TAppStoreSourceMode;
36
37
  private readonly resolveDockerDigests: boolean;
38
+ private readonly requestTimeoutMs: number;
37
39
  private readonly now: () => Date;
38
40
  private appStoreCache: TAppStoreIndex | null = null;
41
+ private inlineAppMetaCache = new Map<string, interfaces.appstore.IAppStoreAppMeta>();
39
42
  private sourceAppCache = new Map<string, IResolvedAppStoreApp>();
40
43
 
41
44
  constructor(optionsArg: IAppStoreResolverOptions = {}) {
@@ -43,6 +46,10 @@ export class AppStoreResolver {
43
46
  this.baseUrl = optionsArg.baseUrl || appStoreDefaultRemoteBaseUrl;
44
47
  this.fetchRef = optionsArg.fetch || fetch;
45
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
+ }
46
53
  this.now = optionsArg.now || (() => new Date());
47
54
  }
48
55
 
@@ -70,13 +77,20 @@ export class AppStoreResolver {
70
77
 
71
78
  public async getAppMeta(appIdArg: string): Promise<interfaces.appstore.IAppStoreAppMeta> {
72
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
+ }
73
83
  if (app?.source?.type === 'repoManifest') {
74
84
  return (await this.resolveRepoManifestSource(app.source, app)).appMeta;
75
85
  }
76
86
  if (app?.source?.type === 'dockerImage') {
77
87
  return this.createAppMetaFromAppStoreApp(app);
78
88
  }
79
- 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);
80
94
  }
81
95
 
82
96
  public async getAppVersionConfig(
@@ -122,6 +136,7 @@ export class AppStoreResolver {
122
136
  public async resolveAppStore(appStoreArg: unknown): Promise<TAppStoreIndex> {
123
137
  const appStore = parseAppStoreIndex(appStoreArg);
124
138
  this.sourceAppCache.clear();
139
+ this.inlineAppMetaCache.clear();
125
140
  const apps: TAppStoreApp[] = [];
126
141
 
127
142
  for (const app of appStore.apps) {
@@ -343,7 +358,10 @@ export class AppStoreResolver {
343
358
  };
344
359
  }
345
360
 
346
- 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);
347
365
  }
348
366
 
349
367
  private async resolveServezoneAppStoreManifest(
@@ -395,9 +413,17 @@ export class AppStoreResolver {
395
413
 
396
414
  const selectedChannel = manifestArg.policy?.defaultChannel || manifestArg.channel || 'stable';
397
415
  const channelVersion = manifestArg.channels?.[selectedChannel];
398
- const declaredLatestVersion = manifestArg.latestVersion || channelVersion || versions[versions.length - 1];
399
- const latestVersion = sourceVersionToResolvedVersion.get(declaredLatestVersion) || declaredLatestVersion;
400
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
+ }
401
427
 
402
428
  const appStoreApp: TAppStoreApp = {
403
429
  id: manifestArg.app.id,
@@ -460,6 +486,50 @@ export class AppStoreResolver {
460
486
  return configArg;
461
487
  }
462
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
+
463
533
  private createAppMetaFromAppStoreApp(appArg: TAppStoreApp): interfaces.appstore.IAppStoreAppMeta {
464
534
  return {
465
535
  id: appArg.id,
@@ -480,6 +550,56 @@ export class AppStoreResolver {
480
550
  return appStore.apps.find((appArg) => appArg.id === appIdArg);
481
551
  }
482
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
+
483
603
  private async fetchJson(pathArg: string): Promise<unknown> {
484
604
  if (this.source === 'package') {
485
605
  const value = appStorePackageData.jsonFiles[pathArg];
@@ -490,11 +610,12 @@ export class AppStoreResolver {
490
610
  }
491
611
 
492
612
  const url = `${this.baseUrl}/${pathArg}`;
493
- const response = await this.fetchRef(url);
494
- if (!response.ok) {
495
- throw new Error(`HTTP ${response.status} for ${url}`);
496
- }
497
- 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
+ });
498
619
  }
499
620
 
500
621
  public async fetchText(pathArg: string): Promise<string> {
@@ -509,19 +630,21 @@ export class AppStoreResolver {
509
630
  }
510
631
 
511
632
  const url = `${this.baseUrl}/${pathArg}`;
512
- const response = await this.fetchRef(url);
513
- if (!response.ok) {
514
- throw new Error(`HTTP ${response.status} for ${url}`);
515
- }
516
- 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
+ });
517
639
  }
518
640
 
519
641
  private async fetchTextFromUrl(urlArg: string): Promise<string> {
520
- const response = await this.fetchRef(urlArg);
521
- if (!response.ok) {
522
- throw new Error(`HTTP ${response.status} for ${urlArg}`);
523
- }
524
- 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
+ });
525
648
  }
526
649
 
527
650
  private async fetchDockerManifestDigest(imageArg: IParsedDockerImageReference): Promise<string | null> {
@@ -535,18 +658,18 @@ export class AppStoreResolver {
535
658
  ].join(', '),
536
659
  });
537
660
 
538
- let response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers });
661
+ let response = await this.fetchRemoteHeaders(manifestUrl, { method: 'HEAD', headers });
539
662
  if (response.status === 401) {
540
663
  const authHeader = response.headers.get('www-authenticate');
541
664
  const token = authHeader ? await this.fetchDockerRegistryToken(authHeader, imageArg.repository) : null;
542
665
  if (token) {
543
666
  headers.set('Authorization', `Bearer ${token}`);
544
- response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers });
667
+ response = await this.fetchRemoteHeaders(manifestUrl, { method: 'HEAD', headers });
545
668
  }
546
669
  }
547
670
 
548
671
  if (!response.ok || !response.headers.get('docker-content-digest')) {
549
- response = await this.fetchRef(manifestUrl, { method: 'GET', headers });
672
+ response = await this.fetchRemoteHeaders(manifestUrl, { method: 'GET', headers });
550
673
  }
551
674
 
552
675
  if (!response.ok) {
@@ -575,10 +698,14 @@ export class AppStoreResolver {
575
698
  if (service) tokenUrl.searchParams.set('service', service);
576
699
  tokenUrl.searchParams.set('scope', scope);
577
700
 
578
- const response = await this.fetchRef(tokenUrl.toString());
579
- if (!response.ok) return null;
580
- const tokenResponse = await response.json() as { token?: string; access_token?: string };
581
- 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;
582
709
  }
583
710
 
584
711
  private validateVolume(volumeArg: interfaces.appstore.IAppStoreVolume, labelArg: string): void {
@@ -754,6 +881,39 @@ async function createSha256Hex(inputArg: string): Promise<string> {
754
881
  .join('');
755
882
  }
756
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
+
757
917
  function uniqueStrings(valuesArg: string[]): string[] {
758
918
  return Array.from(new Set(valuesArg.filter(Boolean)));
759
919
  }
@@ -34,10 +34,11 @@ export const appStorePackageData: IAppStorePackageData = {
34
34
  "description": "Multi-node serve.zone control plane for clusters, workload services, domains, and deployments.",
35
35
  "category": "Dev Tools",
36
36
  "iconName": "server",
37
- "latestVersion": "1.1.0",
37
+ "latestVersion": "1.1.1",
38
38
  "versions": [
39
39
  "1.0.0",
40
- "1.1.0"
40
+ "1.1.0",
41
+ "1.1.1"
41
42
  ],
42
43
  "maintainer": "serve.zone",
43
44
  "links": {
@@ -295,6 +296,131 @@ export const appStorePackageData: IAppStorePackageData = {
295
296
  },
296
297
  "minOneboxVersion": "2.2.0"
297
298
  },
299
+ "apps/cloudly/versions/1.1.1/config.json": {
300
+ "image": "code.foss.global/serve.zone/cloudly@sha256:510b3edb65eed00d3edce4d482f90c864b26b759e3c8f7605109516791fb47f9",
301
+ "targetPorts": [
302
+ {
303
+ "name": "web",
304
+ "port": 3000,
305
+ "protocol": "http",
306
+ "default": true
307
+ }
308
+ ],
309
+ "envVars": [
310
+ {
311
+ "key": "SERVEZONE_ENVIRONMENT",
312
+ "value": "production",
313
+ "description": "Cloudly runtime environment.",
314
+ "required": true
315
+ },
316
+ {
317
+ "key": "SERVEZONE_PUBLIC_ORIGIN",
318
+ "value": "https://${SERVICE_DOMAIN}",
319
+ "description": "Canonical public Cloudly origin including protocol and optional public port.",
320
+ "required": true
321
+ },
322
+ {
323
+ "key": "SERVEZONE_LISTENPORT",
324
+ "value": "3000",
325
+ "description": "Internal Cloudly HTTP port inside the container.",
326
+ "required": true
327
+ },
328
+ {
329
+ "key": "SERVEZONE_SSLMODE",
330
+ "value": "external",
331
+ "description": "Use external TLS termination through Onebox or dcrouter.",
332
+ "required": true
333
+ },
334
+ {
335
+ "key": "MONGODB_URL",
336
+ "value": "${MONGODB_URI}",
337
+ "description": "Authenticated MongoDB connection URL provisioned by Onebox.",
338
+ "required": true
339
+ },
340
+ {
341
+ "key": "MONGODB_NAME",
342
+ "value": "${MONGODB_DATABASE}",
343
+ "description": "MongoDB database name provisioned by Onebox.",
344
+ "required": true
345
+ },
346
+ {
347
+ "key": "MONGODB_USER",
348
+ "value": "${MONGODB_USERNAME}",
349
+ "description": "MongoDB username provisioned by Onebox.",
350
+ "required": true
351
+ },
352
+ {
353
+ "key": "MONGODB_PASS",
354
+ "value": "${MONGODB_PASSWORD}",
355
+ "description": "MongoDB password provisioned by Onebox.",
356
+ "required": true
357
+ },
358
+ {
359
+ "key": "S3_ENDPOINT",
360
+ "value": "onebox-minio",
361
+ "description": "S3 endpoint host for the MinIO service provisioned by Onebox.",
362
+ "required": true
363
+ },
364
+ {
365
+ "key": "S3_PORT",
366
+ "value": "9000",
367
+ "description": "S3 endpoint port for the MinIO service provisioned by Onebox.",
368
+ "required": true
369
+ },
370
+ {
371
+ "key": "S3_USESSL",
372
+ "value": "false",
373
+ "description": "Use plain HTTP for internal MinIO traffic on the Onebox network.",
374
+ "required": true
375
+ },
376
+ {
377
+ "key": "S3_BUCKET",
378
+ "value": "${S3_BUCKET}",
379
+ "description": "S3 bucket provisioned by Onebox for Cloudly's registry.",
380
+ "required": true
381
+ },
382
+ {
383
+ "key": "S3_ACCESSKEY",
384
+ "value": "${S3_ACCESS_KEY}",
385
+ "description": "S3 access key provisioned by Onebox.",
386
+ "required": true
387
+ },
388
+ {
389
+ "key": "S3_SECRETKEY",
390
+ "value": "${S3_SECRET_KEY}",
391
+ "description": "S3 secret key provisioned by Onebox.",
392
+ "required": true
393
+ }
394
+ ],
395
+ "platformRequirements": {
396
+ "mongodb": true,
397
+ "s3": true
398
+ },
399
+ "platformOidc": {
400
+ "redirectPath": "/auth/oidc/callback",
401
+ "roles": [
402
+ {
403
+ "id": "admin",
404
+ "label": "Administrator",
405
+ "description": "Full Cloudly administration for this hosted instance."
406
+ },
407
+ {
408
+ "id": "user",
409
+ "label": "User",
410
+ "description": "Standard authenticated Cloudly access for this hosted instance."
411
+ }
412
+ ],
413
+ "environmentVariables": {
414
+ "issuerUrl": "SERVEZONE_PLATFORM_OIDC_ISSUER",
415
+ "clientId": "SERVEZONE_PLATFORM_OIDC_CLIENT_ID",
416
+ "clientSecret": "SERVEZONE_PLATFORM_OIDC_CLIENT_SECRET",
417
+ "redirectUri": "SERVEZONE_PLATFORM_OIDC_REDIRECT_URI",
418
+ "audience": "SERVEZONE_PLATFORM_OIDC_AUDIENCE"
419
+ },
420
+ "clientAuthenticationMethod": "client_secret_basic"
421
+ },
422
+ "minOneboxVersion": "2.2.0"
423
+ },
298
424
  "apps/ghost/app.json": {
299
425
  "id": "ghost",
300
426
  "name": "Ghost",
@@ -1118,7 +1244,7 @@ export const appStorePackageData: IAppStorePackageData = {
1118
1244
  },
1119
1245
  "appstore.json": {
1120
1246
  "schemaVersion": 1,
1121
- "updatedAt": "2026-07-21T00:00:00Z",
1247
+ "updatedAt": "2026-07-25T00:00:00Z",
1122
1248
  "apps": [
1123
1249
  {
1124
1250
  "id": "nginx",
@@ -1326,7 +1452,7 @@ export const appStorePackageData: IAppStorePackageData = {
1326
1452
  "description": "Multi-node serve.zone control plane for clusters, workload services, domains, and deployments.",
1327
1453
  "category": "Dev Tools",
1328
1454
  "iconName": "server",
1329
- "latestVersion": "1.0.0",
1455
+ "latestVersion": "1.1.1",
1330
1456
  "tags": [
1331
1457
  "serve.zone",
1332
1458
  "control-plane",
@@ -32,10 +32,11 @@ const resolver = new AppStoreResolver({
32
32
  source: 'remote',
33
33
  baseUrl: 'https://example.com/appstore',
34
34
  fetch,
35
+ requestTimeoutMs: 30_000,
35
36
  });
36
37
  ```
37
38
 
38
- Providing `baseUrl` or `fetch` without an explicit `source` also selects remote mode. The default remote base URL is exported as `appStoreDefaultRemoteBaseUrl`.
39
+ Providing `baseUrl` or `fetch` without an explicit `source` also selects remote mode. The default remote base URL is exported as `appStoreDefaultRemoteBaseUrl`. `requestTimeoutMs` bounds each remote catalog, manifest, registry, and response-body operation; it defaults to `30000` and must be a positive integer.
39
40
 
40
41
  ## Exports
41
42
 
@@ -44,6 +45,8 @@ Providing `baseUrl` or `fetch` without an explicit `source` also selects remote
44
45
  - `appStoreDefaultRemoteBaseUrl` exposes the default remote base URL for explicit remote consumers.
45
46
  - `IAppStoreResolverOptions`, `IAppStoreSourceInfo`, and the `TAppStore*` types describe resolver inputs and returned App Store data shapes.
46
47
 
48
+ The resolver rejects inconsistent catalogs before returning them: index and app metadata IDs/latest versions must agree, metadata `latestVersion` must occur in `versions`, and an all-semver list must name its highest semantic value as latest. A repo manifest with multiple non-semver versions must declare explicit latest or channel intent.
49
+
47
50
  ## License and Legal Information
48
51
 
49
52
  This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
@@ -54,6 +54,7 @@ export interface IAppStoreResolverOptions {
54
54
  baseUrl?: string;
55
55
  fetch?: TAppStoreFetch;
56
56
  resolveDockerDigests?: boolean;
57
+ requestTimeoutMs?: number;
57
58
  now?: () => Date;
58
59
  }
59
60