import type * as interfaces from '@serve.zone/interfaces'; import type { IAppStoreResolverOptions, IParsedDockerImageReference, IResolvedAppStoreApp, TAppStoreApp, TAppStoreDockerImageSource, TAppStoreIndex, TAppStoreRepoManifestSource, TAppStoreResolvedSource, TAppStoreVersionConfig, TServezoneAppStoreManifest, } from './types.js'; export class AppStoreResolver { public readonly baseUrl: string; private readonly fetchRef: typeof fetch; private readonly resolveDockerDigests: boolean; private readonly now: () => Date; private appStoreCache: TAppStoreIndex | null = null; private sourceAppCache = new Map(); constructor(optionsArg: IAppStoreResolverOptions = {}) { this.baseUrl = optionsArg.baseUrl || 'https://code.foss.global/serve.zone/appstore/raw/branch/main'; this.fetchRef = optionsArg.fetch || fetch; this.resolveDockerDigests = optionsArg.resolveDockerDigests ?? true; this.now = optionsArg.now || (() => new Date()); } public async getAppStoreIndex(): Promise { if (this.appStoreCache) { return this.appStoreCache; } const appStore = await this.fetchAppStoreIndex(); this.appStoreCache = await this.resolveAppStore(appStore); return this.appStoreCache; } public async getApps(): Promise { return (await this.getAppStoreIndex()).apps; } public async getAppMeta(appIdArg: string): Promise { const app = await this.getAppStoreApp(appIdArg); if (app?.source?.type === 'repoManifest') { return (await this.resolveRepoManifestSource(app.source, app)).appMeta; } if (app?.source?.type === 'dockerImage') { return this.createAppMetaFromAppStoreApp(app); } return await this.fetchJson(`apps/${appIdArg}/app.json`) as interfaces.appstore.IAppStoreAppMeta; } public async getAppVersionConfig( appIdArg: string, versionArg: string, ): Promise { const app = await this.getAppStoreApp(appIdArg); if (app?.source?.type === 'repoManifest') { const resolvedApp = await this.resolveRepoManifestSource(app.source, app); const config = resolvedApp.configsByVersion.get(versionArg); if (!config) { throw new Error(`Version '${versionArg}' is not defined by the linked appstore manifest`); } this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`); return config; } if (app?.source?.type === 'dockerImage' && app.runtime) { const config: TAppStoreVersionConfig = { ...app.runtime }; await this.applyDockerImageSourceToConfig(app.source, config, versionArg); this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`); return config; } let config: TAppStoreVersionConfig; try { config = await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as TAppStoreVersionConfig; } catch (error) { if (app?.source?.type !== 'dockerImage') { throw error; } const appMeta = await this.fetchJson(`apps/${appIdArg}/app.json`) as interfaces.appstore.IAppStoreAppMeta; config = await this.fetchJson(`apps/${appIdArg}/versions/${appMeta.latestVersion}/config.json`) as TAppStoreVersionConfig; } if (app?.source?.type === 'dockerImage') { await this.applyDockerImageSourceToConfig(app.source, config, versionArg); } this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`); return config; } public async resolveAppStore(appStoreArg: unknown): Promise { const appStore = parseAppStoreIndex(appStoreArg); this.sourceAppCache.clear(); const apps: TAppStoreApp[] = []; for (const app of appStore.apps) { apps.push(await this.resolveAppStoreApp(app)); } return { ...appStore, apps, resolvedAt: this.now().toISOString(), }; } public async resolveRepoManifestSource( sourceArg: TAppStoreRepoManifestSource, appArg?: TAppStoreApp, ): Promise { const cacheKey = `${sourceArg.url}#${sourceArg.ref || ''}`; const cachedApp = this.sourceAppCache.get(cacheKey); if (cachedApp) { return cachedApp; } const manifestText = await this.fetchTextFromUrl(sourceArg.url); const manifestHash = await createSha256Hex(manifestText); const manifest = parseServezoneAppStoreManifest(JSON.parse(manifestText)); const resolvedApp = await this.resolveServezoneAppStoreManifest(manifest, { type: 'repoManifest', url: sourceArg.url, ref: sourceArg.ref, manifestHash, resolvedAt: this.now().toISOString(), }); if (appArg) { resolvedApp.appStoreApp = { ...resolvedApp.appStoreApp, ...withoutUndefined(appArg), latestVersion: resolvedApp.appStoreApp.latestVersion, versions: resolvedApp.appStoreApp.versions, source: appArg.source, tags: appArg.tags || resolvedApp.appStoreApp.tags, resolvedSource: resolvedApp.appStoreApp.resolvedSource, }; resolvedApp.appMeta = { ...resolvedApp.appMeta, id: resolvedApp.appStoreApp.id, name: resolvedApp.appStoreApp.name, description: resolvedApp.appStoreApp.description, category: resolvedApp.appStoreApp.category, iconName: resolvedApp.appStoreApp.iconName, latestVersion: resolvedApp.appStoreApp.latestVersion, versions: resolvedApp.appStoreApp.versions || resolvedApp.appMeta.versions, tags: resolvedApp.appStoreApp.tags, source: appArg.source, resolvedSource: resolvedApp.appStoreApp.resolvedSource, }; } this.sourceAppCache.set(cacheKey, resolvedApp); return resolvedApp; } public async resolveDockerImageSource( sourceArg: TAppStoreDockerImageSource, ): Promise { let imageDigest: string | undefined; if (sourceArg.tracking === 'digest' && this.resolveDockerDigests) { imageDigest = await this.resolveDockerImageDigest(sourceArg.image) || undefined; } return { type: 'dockerImage', image: sourceArg.image, imageDigest, resolvedAt: this.now().toISOString(), }; } public async resolveDockerImageDigest(imageArg: string): Promise { const parsedImage = parseDockerImageReference(imageArg); if (parsedImage.digest) { return parsedImage.digest; } return await this.fetchDockerManifestDigest(parsedImage); } public validateAppStoreVersionConfig( configArg: TAppStoreVersionConfig, labelArg = 'appstore config', ): void { if (!configArg || typeof configArg !== 'object') { throw new Error(`Invalid ${labelArg}: config must be an object`); } if (!configArg.image || typeof configArg.image !== 'string') { throw new Error(`Invalid ${labelArg}: image is required`); } this.assertValidPort(configArg.port, `${labelArg} port`); for (const envVar of configArg.envVars || []) { if (!envVar.key || !/^[A-Z_][A-Z0-9_]*$/.test(envVar.key)) { throw new Error(`Invalid ${labelArg}: env var key '${envVar.key}' is not valid`); } if (envVar.value !== undefined && typeof envVar.value !== 'string') { throw new Error(`Invalid ${labelArg}: env var '${envVar.key}' value must be a string`); } } this.normalizeVolumes(configArg.volumes); this.validatePublishedPorts(configArg.publishedPorts || [], labelArg); } public normalizeVolumes( volumesArg: TAppStoreVersionConfig['volumes'] = [], ): interfaces.appstore.IAppStoreVolume[] { return volumesArg.map((volumeArg): interfaces.appstore.IAppStoreVolume => { if (typeof volumeArg === 'string') { return { mountPath: volumeArg }; } return volumeArg; }).map((volumeArg, indexArg) => { this.validateVolume(volumeArg, `volume ${indexArg + 1}`); return volumeArg; }); } private async fetchAppStoreIndex(): Promise { try { return await this.fetchJson('appstore.resolved.json') as TAppStoreIndex; } catch { return await this.fetchJson('appstore.json') as TAppStoreIndex; } } private async resolveAppStoreApp(appArg: TAppStoreApp): Promise { if (appArg.source?.type === 'repoManifest') { const resolvedApp = await this.resolveRepoManifestSource(appArg.source, appArg); return { ...resolvedApp.appStoreApp, ...withoutUndefined(appArg), latestVersion: resolvedApp.appStoreApp.latestVersion, versions: resolvedApp.appStoreApp.versions, source: appArg.source, tags: appArg.tags || resolvedApp.appStoreApp.tags, resolvedSource: resolvedApp.appStoreApp.resolvedSource, }; } if (appArg.source?.type === 'dockerImage') { const config = appArg.runtime ? { ...appArg.runtime } : undefined; const resolvedSource = config ? (await this.applyDockerImageSourceToConfig(appArg.source, config, appArg.latestVersion)).resolvedSource : await this.resolveDockerImageSource(appArg.source); const latestVersion = createAppStoreVersionForDockerSource( appArg.source, appArg.latestVersion, resolvedSource?.imageDigest, ); return { ...appArg, runtime: config, latestVersion, versions: uniqueStrings([...(appArg.versions || []), latestVersion]), upgradeStrategy: appArg.source.tracking === 'digest' ? 'dockerDigest' : appArg.upgradeStrategy, resolvedSource, }; } return appArg; } private async resolveServezoneAppStoreManifest( manifestArg: TServezoneAppStoreManifest, resolvedSourceArg: TAppStoreResolvedSource, ): Promise { const configsByVersion = new Map(); const versions: string[] = []; const sourceVersionToResolvedVersion = new Map(); for (const versionArg of manifestArg.versions || []) { const sourceVersion = versionArg.version; const { version: _version, ...versionConfig } = versionArg; const config: TAppStoreVersionConfig = { ...versionConfig, source: versionConfig.source || manifestArg.source, resolvedSource: resolvedSourceArg, }; await this.resolveConfigSource(config, sourceVersion); const resolvedVersion = config.appStoreVersion || sourceVersion; config.appStoreVersion = resolvedVersion; this.validateAppStoreVersionConfig(config, `${manifestArg.app.id}@${resolvedVersion}`); configsByVersion.set(resolvedVersion, config); configsByVersion.set(sourceVersion, config); versions.push(resolvedVersion); sourceVersionToResolvedVersion.set(sourceVersion, resolvedVersion); } if (manifestArg.runtime) { const sourceVersion = manifestArg.latestVersion || manifestArg.channel || 'latest'; const config: TAppStoreVersionConfig = { ...manifestArg.runtime, source: manifestArg.runtime.source || manifestArg.source, resolvedSource: resolvedSourceArg, }; await this.resolveConfigSource(config, sourceVersion); const resolvedVersion = config.appStoreVersion || sourceVersion; config.appStoreVersion = resolvedVersion; this.validateAppStoreVersionConfig(config, `${manifestArg.app.id}@${resolvedVersion}`); configsByVersion.set(resolvedVersion, config); configsByVersion.set(sourceVersion, config); versions.push(resolvedVersion); sourceVersionToResolvedVersion.set(sourceVersion, resolvedVersion); } if (configsByVersion.size === 0) { throw new Error('Appstore manifest must define at least one runtime config or version'); } const selectedChannel = manifestArg.policy?.defaultChannel || manifestArg.channel || 'stable'; const channelVersion = manifestArg.channels?.[selectedChannel]; const declaredLatestVersion = manifestArg.latestVersion || channelVersion || versions[versions.length - 1]; const latestVersion = sourceVersionToResolvedVersion.get(declaredLatestVersion) || declaredLatestVersion; const uniqueVersions = uniqueStrings(versions); const appStoreApp: TAppStoreApp = { id: manifestArg.app.id, name: manifestArg.app.name, description: manifestArg.app.description, category: manifestArg.app.category, iconName: manifestArg.app.iconName, iconUrl: manifestArg.app.iconUrl, latestVersion, versions: uniqueVersions, tags: manifestArg.app.tags, channel: selectedChannel, source: manifestArg.source, upgradeStrategy: getUpgradeStrategyForConfig(configsByVersion.get(latestVersion)), resolvedSource: resolvedSourceArg, }; const appMeta: interfaces.appstore.IAppStoreAppMeta = { id: manifestArg.app.id, name: manifestArg.app.name, description: manifestArg.app.description, category: manifestArg.app.category, iconName: manifestArg.app.iconName, latestVersion, versions: uniqueVersions, maintainer: manifestArg.app.maintainer, links: manifestArg.app.links, tags: manifestArg.app.tags, source: manifestArg.source, resolvedSource: resolvedSourceArg, }; return { appStoreApp, appMeta, configsByVersion }; } private async resolveConfigSource(configArg: TAppStoreVersionConfig, versionArg: string): Promise { if (configArg.source?.type === 'dockerImage') { await this.applyDockerImageSourceToConfig(configArg.source, configArg, versionArg); } } private async applyDockerImageSourceToConfig( sourceArg: TAppStoreDockerImageSource, configArg: TAppStoreVersionConfig, versionArg: string, ): Promise { configArg.image = sourceArg.image; configArg.source = sourceArg; const resolvedSource = await this.resolveDockerImageSource(sourceArg); configArg.resolvedSource = resolvedSource; configArg.resolvedImageDigest = resolvedSource.imageDigest; configArg.upgradeStrategy = sourceArg.tracking === 'digest' ? 'dockerDigest' : configArg.upgradeStrategy; configArg.appStoreVersion = createAppStoreVersionForDockerSource( sourceArg, versionArg, resolvedSource.imageDigest, ); return configArg; } private createAppMetaFromAppStoreApp(appArg: TAppStoreApp): interfaces.appstore.IAppStoreAppMeta { return { id: appArg.id, name: appArg.name, description: appArg.description, category: appArg.category, iconName: appArg.iconName, latestVersion: appArg.latestVersion, versions: appArg.versions || [appArg.latestVersion], tags: appArg.tags, source: appArg.source, resolvedSource: appArg.resolvedSource, }; } private async getAppStoreApp(appIdArg: string): Promise { const appStore = await this.getAppStoreIndex(); return appStore.apps.find((appArg) => appArg.id === appIdArg); } private async fetchJson(pathArg: string): Promise { const url = `${this.baseUrl}/${pathArg}`; const response = await this.fetchRef(url); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return response.json(); } private async fetchTextFromUrl(urlArg: string): Promise { const response = await this.fetchRef(urlArg); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${urlArg}`); } return response.text(); } private async fetchDockerManifestDigest(imageArg: IParsedDockerImageReference): Promise { const manifestUrl = `https://${imageArg.registry}/v2/${imageArg.repository}/manifests/${imageArg.tag}`; const headers = new Headers({ Accept: [ 'application/vnd.docker.distribution.manifest.v2+json', 'application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json', 'application/vnd.oci.image.index.v1+json', ].join(', '), }); let response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers }); if (response.status === 401) { const authHeader = response.headers.get('www-authenticate'); const token = authHeader ? await this.fetchDockerRegistryToken(authHeader, imageArg.repository) : null; if (token) { headers.set('Authorization', `Bearer ${token}`); response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers }); } } if (!response.ok || !response.headers.get('docker-content-digest')) { response = await this.fetchRef(manifestUrl, { method: 'GET', headers }); } if (!response.ok) { throw new Error(`HTTP ${response.status} while resolving ${imageArg.repository}:${imageArg.tag}`); } return response.headers.get('docker-content-digest'); } private async fetchDockerRegistryToken(authHeaderArg: string, repositoryArg: string): Promise { const match = authHeaderArg.match(/^Bearer\s+(.+)$/i); if (!match) return null; const authParams = new Map(); for (const partArg of match[1].match(/(?:[^,\"]+|\"[^\"]*\")+/g) || []) { const [key, rawValue] = partArg.split('='); if (!key || rawValue === undefined) continue; authParams.set(key.trim(), rawValue.trim().replace(/^\"|\"$/g, '')); } const realm = authParams.get('realm'); if (!realm) return null; const tokenUrl = new URL(realm); const service = authParams.get('service'); const scope = authParams.get('scope') || `repository:${repositoryArg}:pull`; if (service) tokenUrl.searchParams.set('service', service); tokenUrl.searchParams.set('scope', scope); const response = await this.fetchRef(tokenUrl.toString()); if (!response.ok) return null; const tokenResponse = await response.json() as { token?: string; access_token?: string }; return tokenResponse.token || tokenResponse.access_token || null; } private validateVolume(volumeArg: interfaces.appstore.IAppStoreVolume, labelArg: string): void { if (!volumeArg.mountPath || !volumeArg.mountPath.startsWith('/')) { throw new Error(`Invalid ${labelArg}: mountPath must be an absolute path`); } if (volumeArg.mountPath.includes(':')) { throw new Error(`Invalid ${labelArg}: mountPath must not contain ':'`); } if ((volumeArg.source || volumeArg.name)?.includes(':')) { throw new Error(`Invalid ${labelArg}: source/name must not contain ':'`); } } private validatePublishedPorts( publishedPortsArg: TAppStoreVersionConfig['publishedPorts'] = [], labelArg: string, ): void { const seenPublishedPorts = new Set(); for (const portArg of publishedPortsArg) { const protocol = portArg.protocol || 'tcp'; const targetStart = portArg.targetPort; const targetEnd = portArg.targetPortEnd || targetStart; const publishedStart = portArg.publishedPort || targetStart; const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart)); const hostIp = portArg.hostIp || '0.0.0.0'; if (!['tcp', 'udp'].includes(protocol)) { throw new Error(`Invalid ${labelArg}: published port protocol '${protocol}' is not supported`); } this.assertValidPort(targetStart, `${labelArg} targetPort`); this.assertValidPort(targetEnd, `${labelArg} targetPortEnd`); this.assertValidPort(publishedStart, `${labelArg} publishedPort`); this.assertValidPort(publishedEnd, `${labelArg} publishedPortEnd`); if (targetEnd < targetStart || publishedEnd < publishedStart) { throw new Error(`Invalid ${labelArg}: published port ranges must be ascending`); } if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) { throw new Error(`Invalid ${labelArg}: target and published port ranges must have the same size`); } if ((targetEnd - targetStart) > 1000) { throw new Error(`Invalid ${labelArg}: published port ranges may not exceed 1001 ports`); } for (let offset = 0; offset <= targetEnd - targetStart; offset++) { const publishedPort = publishedStart + offset; const publishedKey = `${hostIp}/${protocol}/${publishedPort}`; const wildcardKey = `0.0.0.0/${protocol}/${publishedPort}`; const conflictsWithWildcard = hostIp === '0.0.0.0' ? Array.from(seenPublishedPorts).some((keyArg) => keyArg.endsWith(`/${protocol}/${publishedPort}`)) : seenPublishedPorts.has(wildcardKey); if (seenPublishedPorts.has(publishedKey) || conflictsWithWildcard) { throw new Error(`Invalid ${labelArg}: duplicate published port ${hostIp}:${publishedPort}/${protocol}`); } seenPublishedPorts.add(publishedKey); } } } private assertValidPort(portArg: number, labelArg: string): void { if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) { throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer port between 1 and 65535.`); } } } export function parseAppStoreIndex(inputArg: unknown): TAppStoreIndex { const appStore = inputArg as TAppStoreIndex; if (!appStore || typeof appStore !== 'object' || !Array.isArray(appStore.apps)) { throw new Error('Invalid appstore format'); } return appStore; } export function parseServezoneAppStoreManifest(inputArg: unknown): TServezoneAppStoreManifest { const manifest = inputArg as TServezoneAppStoreManifest; if (!manifest || typeof manifest !== 'object') { throw new Error('Appstore manifest must be an object'); } if (manifest.schemaVersion !== 1) { throw new Error(`Unsupported appstore manifest schemaVersion '${manifest.schemaVersion}'`); } if (!manifest.app?.id || !manifest.app?.name) { throw new Error('Appstore manifest app.id and app.name are required'); } return manifest; } export function parseDockerImageReference(imageArg: string): IParsedDockerImageReference { const [imageWithoutDigest, digest] = imageArg.split('@'); const imageParts = imageWithoutDigest.split('/'); const firstPart = imageParts[0]; const hasExplicitRegistry = imageParts.length > 1 && (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost'); const registry = hasExplicitRegistry ? firstPart : 'registry-1.docker.io'; const repositoryParts = hasExplicitRegistry ? imageParts.slice(1) : imageParts; let repositoryWithTag = repositoryParts.join('/'); if (!repositoryWithTag) { throw new Error(`Invalid Docker image reference '${imageArg}'`); } if (!hasExplicitRegistry && !repositoryWithTag.includes('/')) { repositoryWithTag = `library/${repositoryWithTag}`; } const lastSlashIndex = repositoryWithTag.lastIndexOf('/'); const lastColonIndex = repositoryWithTag.lastIndexOf(':'); const hasTag = lastColonIndex > lastSlashIndex; const repository = hasTag ? repositoryWithTag.slice(0, lastColonIndex) : repositoryWithTag; const tag = hasTag ? repositoryWithTag.slice(lastColonIndex + 1) : 'latest'; return { registry, repository, tag, digest }; } export function createAppStoreVersionForDockerSource( sourceArg: TAppStoreDockerImageSource, fallbackVersionArg: string, digestArg?: string, ): string { if (sourceArg.tracking !== 'digest' || !digestArg) { return fallbackVersionArg; } const parsedImage = parseDockerImageReference(sourceArg.image); return `${parsedImage.tag}@${digestArg}`; } async function createSha256Hex(inputArg: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(inputArg)); return Array.from(new Uint8Array(digest)) .map((byteArg) => byteArg.toString(16).padStart(2, '0')) .join(''); } function uniqueStrings(valuesArg: string[]): string[] { return Array.from(new Set(valuesArg.filter(Boolean))); } function withoutUndefined(objectArg: T): Partial { return Object.fromEntries( Object.entries(objectArg).filter(([, valueArg]) => valueArg !== undefined), ) as Partial; } function getUpgradeStrategyForConfig( configArg?: TAppStoreVersionConfig, ): interfaces.appstore.IAppStoreApp['upgradeStrategy'] { if (configArg?.upgradeStrategy) return configArg.upgradeStrategy; if (configArg?.source?.type === 'dockerImage' && configArg.source.tracking === 'digest') return 'dockerDigest'; return undefined; }