import { createAppStoreInstallSecretEnvelopeContext, validateSecretDelivery, validateSecretValueInput, validateSecretValueInputForRecipient, type TSecretDelivery, type TSecretGeneratedEncoding, type IActiveSecretRecipientMetadata, type TSecretValueInput, } from '../data/secret.js'; import { validateHostedAppRoleDefinitions, type IHostedAppRoleDefinition, } from '../data/hostedapp.js'; import type { IServiceDomainRoute, IServicePublicPortMapping, IServiceTargetPort, } from '../data/serviceports.js'; import type { IStorageCapacityRequest, IStorageClassRequirement, TFilesystemStorageAccessMode, TObjectStorageAccessMode, TObjectStorageDelivery, TStorageReclaimPolicy, TStorageResourceKind, TStorageSnapshotMode, } from '../platform/storage.js'; export type TAppStorePlatformRequirement = 'mongodb' | 's3' | 'clickhouse' | 'valkey' | 'mariadb'; export interface IAppStorePlatformRequirements { mongodb?: boolean; /** * @deprecated `s3: true` is normalized to one legacy object-storage binding. * New templates use a named objectStorage request. */ s3?: boolean; clickhouse?: boolean; valkey?: boolean; mariadb?: boolean; } export type TAppStoreSourceType = 'inline' | 'repoManifest' | 'dockerImage'; export type TAppStoreTrackingMode = 'tag' | 'digest'; export type TAppStoreUpgradeStrategy = 'semver' | 'branch' | 'dockerDigest'; export const appStoreStorageFeatureIds = { bindingsV2: 'storage.bindings.v2', filesystemV1: 'storage.filesystem.v1', objectStorageV2: 'storage.object-storage.v2', objectStorageFileV2: 'storage.object-storage.file.v2', } as const; export type TAppStoreStorageFeatureId = typeof appStoreStorageFeatureIds[keyof typeof appStoreStorageFeatureIds]; export type TAppStoreStoragePurpose = | 'runtime' | 'database' | 'registry' | 'backup'; /** * A template-local logical class. It states portable policy requirements and * preferences; fulfillment adapters select their own compatible policy class. */ export interface IAppStoreStorageClass { kind: TStorageResourceKind; purpose: TAppStoreStoragePurpose; required?: IStorageClassRequirement; preferred?: IStorageClassRequirement; } export interface IAppStoreStorageRequestBase { /** Stable identity used across upgrades, migrations, and restores. */ id: string; kind: TStorageResourceKind; /** Key in the containing version config's storageClasses record. */ storageClass: string; capacity?: IStorageCapacityRequest; reclaimPolicy: TStorageReclaimPolicy; } export interface IAppStoreFilesystemProtection { backup?: 'required'; snapshots?: Exclude; consistency?: 'crashConsistent' | 'applicationConsistent'; } export interface IAppStoreFilesystemStorageRequest extends IAppStoreStorageRequestBase { kind: 'filesystem'; mountPath: string; accessMode: TFilesystemStorageAccessMode; protection?: IAppStoreFilesystemProtection; } export interface IAppStoreObjectStorageProtection { backup?: 'required'; versioning?: 'required'; /** Minimum provider-enforced retention duration. */ retentionDays?: number; } export interface IAppStoreObjectStorageRequest extends IAppStoreStorageRequestBase { kind: 'objectStorage'; accessMode: TObjectStorageAccessMode; delivery: TObjectStorageDelivery; protection?: IAppStoreObjectStorageProtection; } export type TAppStoreStorageRequest = | IAppStoreFilesystemStorageRequest | IAppStoreObjectStorageRequest; export interface IAppStoreInlineSource { type: 'inline'; } export interface IAppStoreRepoManifestSource { type: 'repoManifest'; /** Raw URL to a servezone.appstore.json file. Can point to a branch such as main. */ url: string; /** Optional human-readable ref, for example main, stable, or v1.2.3. */ ref?: string; } export interface IAppStoreDockerImageSource { type: 'dockerImage'; /** Docker image reference. Mutable tags such as :latest are allowed when policy permits them. */ image: string; /** Digest tracking turns mutable tag changes into explicit appstore upgrades. */ tracking?: TAppStoreTrackingMode; } export type TAppStoreSource = | IAppStoreInlineSource | IAppStoreRepoManifestSource | IAppStoreDockerImageSource; export interface IAppStoreResolvedSource { type: TAppStoreSourceType; url?: string; ref?: string; image?: string; manifestHash?: string; imageDigest?: string; resolvedAt: string; } interface IAppStoreEnvironmentDeclarationBase { key: string; description: string; required?: boolean; } export interface IAppStorePublicEnvironmentDeclaration extends IAppStoreEnvironmentDeclarationBase { secret?: false; /** Public nonsecret default. */ value?: string; delivery?: never; generate?: never; } export interface IAppStoreSecretEnvironmentDeclaration extends IAppStoreEnvironmentDeclarationBase { secret: true; value?: never; delivery: TSecretDelivery; generate?: { encoding: TSecretGeneratedEncoding; bytes: number; }; } export type TAppStoreEnvironmentDeclaration = | IAppStorePublicEnvironmentDeclaration | IAppStoreSecretEnvironmentDeclaration; export interface IAppStoreVolume { /** Stable Docker volume name. If omitted, the runtime derives one from service name and mount path. */ name?: string; /** Alias for name when a volume is shared intentionally across services. */ source?: string; /** Container path where the volume is mounted. */ mountPath: string; /** Docker volume driver. Defaults to the runtime's persistent volume driver. */ driver?: string; readOnly?: boolean; /** Whether backup orchestration should snapshot this volume. Defaults to true. */ backup?: boolean; /** Driver-specific options forwarded to the container runtime. */ options?: Record; } export type TAppStoreVolumeSpec = string | IAppStoreVolume; export interface IAppStorePublishedPort { targetPort: number; targetPortEnd?: number; publishedPort?: number; publishedPortEnd?: number; protocol?: 'tcp' | 'udp'; hostIp?: string; } export interface IAppStorePlatformOidcEnvironmentVariables { issuerUrl: string; clientId: string; /** The hosting platform must deliver this value through its secret environment path. */ clientSecret: string; redirectUri: string; audience: string; } export interface IAppStorePlatformOidcCapability { /** Callback path relative to the app's canonical HTTPS origin. */ redirectPath: string; roles: IHostedAppRoleDefinition[]; environmentVariables: IAppStorePlatformOidcEnvironmentVariables; clientAuthenticationMethod: 'client_secret_basic' | 'client_secret_post'; } const appStorePublicEnvKeyRegex = /^[A-Za-z_][A-Za-z0-9_]{0,252}$/; const appStoreSecretKeyRegex = /^[A-Z_][A-Z0-9_]{0,252}$/; export const validateAppStorePlatformOidcCapability = ( capabilityArg: unknown, ): string[] => { if (capabilityArg === undefined) return []; if (!capabilityArg || typeof capabilityArg !== 'object' || Array.isArray(capabilityArg)) { return ['platformOidc must be an object']; } const capability = capabilityArg as Record; const errors: string[] = []; if (typeof capability.redirectPath !== 'string' || !capability.redirectPath.startsWith('/') || capability.redirectPath.startsWith('//') || /[\\?#\s]/.test(capability.redirectPath) || /%5c/i.test(capability.redirectPath) || new URL(capability.redirectPath, 'https://app.invalid').pathname !== capability.redirectPath) { errors.push('platformOidc.redirectPath must be a canonical same-origin path without query or fragment'); } errors.push(...validateHostedAppRoleDefinitions(capability.roles) .map((errorArg) => `platformOidc.${errorArg}`)); const environment = capability.environmentVariables; if (!environment || typeof environment !== 'object' || Array.isArray(environment)) { errors.push('platformOidc.environmentVariables must be an object'); } else { const keys = ['issuerUrl', 'clientId', 'clientSecret', 'redirectUri', 'audience'] as const; const values = keys.map((keyArg) => (environment as Record)[keyArg]); keys.forEach((keyArg, indexArg) => { if (typeof values[indexArg] !== 'string' || !appStoreSecretKeyRegex.test(values[indexArg] as string)) { errors.push(`platformOidc.environmentVariables.${keyArg} must be a valid environment key`); } }); if (new Set(values).size !== values.length) { errors.push('platformOidc environment keys must be unique'); } } if (capability.clientAuthenticationMethod !== 'client_secret_basic' && capability.clientAuthenticationMethod !== 'client_secret_post') { errors.push('platformOidc.clientAuthenticationMethod is unsupported'); } return errors; }; export const validateAppStoreVersionPlatformOidc = ( configArg: unknown, ): string[] => { if (!configArg || typeof configArg !== 'object' || Array.isArray(configArg)) { return ['appstore config must be an object']; } const config = configArg as Record; const errors = [ ...validateAppStorePlatformOidcCapability(config.platformOidc), ...validateAppStoreEnvironmentDeclarations(config.envVars), ]; if (!config.platformOidc || typeof config.platformOidc !== 'object' || Array.isArray(config.platformOidc)) return errors; const capability = config.platformOidc as Record; if (!capability.environmentVariables || typeof capability.environmentVariables !== 'object' || Array.isArray(capability.environmentVariables)) return errors; const oidcKeys = new Set(Object.values(capability.environmentVariables) .filter((valueArg): valueArg is string => typeof valueArg === 'string')); const ordinaryEnvKeys = Array.isArray(config.envVars) ? config.envVars.map((envArg) => ( envArg && typeof envArg === 'object' && !Array.isArray(envArg) ? (envArg as Record).key : undefined )).filter((valueArg): valueArg is string => typeof valueArg === 'string') : []; for (const key of ordinaryEnvKeys) { if (oidcKeys.has(key)) { errors.push(`platformOidc environment key '${key}' collides with app runtime input`); } } return errors; }; export interface IAppStoreApp { id: string; name: string; description: string; category: string; iconName?: string; iconUrl?: string; latestVersion: string; versions?: string[]; tags?: string[]; channel?: string; upgradeStrategy?: TAppStoreUpgradeStrategy; source?: TAppStoreSource; /** Minimal runtime config for source-only appstore entries, typically dockerImage sources. */ runtime?: IAppStoreVersionConfig; resolvedSource?: IAppStoreResolvedSource; } export interface IAppStoreIndex { schemaVersion: number; updatedAt: string; resolvedAt?: string; apps: IAppStoreApp[]; } export interface IAppStoreAppMeta { id: string; name: string; description: string; category: string; iconName?: string; latestVersion: string; versions: string[]; maintainer?: string; links?: Record; tags?: string[]; source?: TAppStoreSource; resolvedSource?: IAppStoreResolvedSource; } export interface IAppStoreVersionConfig { image: string; /** Legacy shorthand for targetPorts.web. New templates should prefer targetPorts. */ port?: number; /** Exact OCI/Docker argument vector appended after the image entrypoint. */ containerArgs?: string[]; targetPorts?: IServiceTargetPort[]; domains?: IServiceDomainRoute[]; /** Edge/coretraffic public TCP/UDP exposure, distinct from Docker publishedPorts. */ publicPortMappings?: IServicePublicPortMapping[]; envVars?: TAppStoreEnvironmentDeclaration[]; /** * @deprecated Legacy volume syntax. New templates use storageClasses and * storageRequests. Resolver normalization must reject legacy physical driver * options that cannot be represented portably. */ volumes?: TAppStoreVolumeSpec[]; /** * Template-local logical policy classes. Keys are stable within the * template; they are not Onebox or Cloudly operator class names. */ storageClasses?: Record; /** Stable named filesystem and managed object-storage requests. */ storageRequests?: TAppStoreStorageRequest[]; publishedPorts?: IAppStorePublishedPort[]; platformRequirements?: IAppStorePlatformRequirements; /** * Declares that this workload can consume platform-managed OIDC. The host * still requires an explicit per-instance administrator toggle before it * creates a client, injects credentials, or emits app-scoped role claims. */ platformOidc?: IAppStorePlatformOidcCapability; minOneboxVersion?: string; minCloudlyVersion?: string; appStoreVersion?: string; upgradeStrategy?: TAppStoreUpgradeStrategy; source?: TAppStoreSource; resolvedSource?: IAppStoreResolvedSource; resolvedImageDigest?: string; changelog?: string; breaking?: boolean; requiresManualReview?: boolean; migrationRequired?: boolean; backupBeforeUpgrade?: boolean; requiresFeatures?: string[]; healthCheck?: { path?: string; port?: number; expectedStatus?: number; }; } export interface IServezoneAppStoreAppInfo { id: string; name: string; description: string; category: string; iconName?: string; iconUrl?: string; tags?: string[]; maintainer?: string; links?: Record; } export interface IServezoneAppStoreVersion extends IAppStoreVersionConfig { version: string; } export interface IServezoneAppStoreManifest { schemaVersion: number; app: IServezoneAppStoreAppInfo; latestVersion?: string; channel?: string; channels?: Record; source?: TAppStoreSource; runtime?: IAppStoreVersionConfig; versions?: IServezoneAppStoreVersion[]; policy?: { allowMutableImage?: boolean; defaultChannel?: string; }; } export interface IAppStoreInstallRequest { mutationId: string; appId: string; version: string; serviceId: string; serviceName: string; domain?: string; /** Legacy install-time override for the web target port. */ port?: number; publishedPorts?: IAppStorePublishedPort[]; publicPortMappings?: IServicePublicPortMapping[]; publicEnvironment: Record; secretInputs: Array<{ key: string; valueInput: TSecretValueInput }>; } const appStoreMutationIdRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/; export const validateAppStoreEnvironmentDeclarations = ( declarationsArg: unknown, ): string[] => { if (declarationsArg === undefined) return []; if (!Array.isArray(declarationsArg) || declarationsArg.length > 256) { return ['envVars must be a bounded array']; } const errors: string[] = []; const keys: string[] = []; declarationsArg.forEach((declarationArg, indexArg) => { if (!declarationArg || typeof declarationArg !== 'object' || Array.isArray(declarationArg)) { errors.push(`envVars[${indexArg}] must be an object`); return; } const declaration = declarationArg as Record; const allowedKeys = declaration.secret === true ? new Set(['key', 'description', 'required', 'secret', 'delivery', 'generate']) : new Set(['key', 'description', 'required', 'secret', 'value']); if (Object.keys(declaration).some((keyArg) => !allowedKeys.has(keyArg))) { errors.push(`envVars[${indexArg}] contains fields outside its exact channel schema`); } const keyRegex = declaration.secret === true ? appStoreSecretKeyRegex : appStorePublicEnvKeyRegex; if (typeof declaration.key !== 'string' || !keyRegex.test(declaration.key)) { errors.push(declaration.secret === true ? `envVars[${indexArg}].key must be a canonical secret key` : `envVars[${indexArg}].key must be a valid public environment key`); } else { keys.push(declaration.key); } if (typeof declaration.description !== 'string' || declaration.description.length > 1000) { errors.push(`envVars[${indexArg}].description must be bounded`); } if (declaration.required !== undefined && typeof declaration.required !== 'boolean') { errors.push(`envVars[${indexArg}].required must be boolean`); } if (declaration.secret === true) { if ('value' in declaration) errors.push(`envVars[${indexArg}] secret declaration forbids value`); if (!('delivery' in declaration)) { errors.push(`envVars[${indexArg}] secret declaration requires delivery`); } else { errors.push(...validateSecretDelivery(declaration.delivery) .map((errorArg) => `envVars[${indexArg}].${errorArg}`)); } if (declaration.generate !== undefined) { errors.push(...validateSecretValueInput({ mode: 'generated', ...(declaration.generate as Record), }).map((errorArg) => `envVars[${indexArg}].generate ${errorArg}`)); } } else { if (declaration.secret !== undefined && declaration.secret !== false) { errors.push(`envVars[${indexArg}].secret must be true or false`); } if ('delivery' in declaration) errors.push(`envVars[${indexArg}] public declaration forbids delivery`); if ('generate' in declaration) errors.push(`envVars[${indexArg}] public declaration forbids generate`); if (declaration.value !== undefined && typeof declaration.value !== 'string') { errors.push(`envVars[${indexArg}].value must be a public string default`); } } }); if (new Set(keys).size !== keys.length) errors.push('envVars keys must be unique'); return errors; }; export const validateAppStoreInstallRequest = async ( requestArg: unknown, configArg: IAppStoreVersionConfig, activeIngressRecipientArg: IActiveSecretRecipientMetadata, ): Promise => { if (!requestArg || typeof requestArg !== 'object' || Array.isArray(requestArg)) { return ['install request must be an object']; } const request = requestArg as Record; const rawDeclarations = configArg && typeof configArg === 'object' ? configArg.envVars : undefined; const errors = validateAppStoreEnvironmentDeclarations(rawDeclarations); const allowedRequestKeys = new Set([ 'mutationId', 'appId', 'version', 'serviceId', 'serviceName', 'domain', 'port', 'publishedPorts', 'publicPortMappings', 'publicEnvironment', 'secretInputs', ]); if (Object.keys(request).some((keyArg) => !allowedRequestKeys.has(keyArg))) { errors.push('install request contains fields outside its schema'); } if (typeof request.mutationId !== 'string' || !appStoreMutationIdRegex.test(request.mutationId)) { errors.push('install mutationId must be canonical'); } for (const field of ['appId', 'version', 'serviceId', 'serviceName'] as const) { if (typeof request[field] !== 'string' || !(request[field] as string).trim()) { errors.push(`install ${field} must be non-empty`); } } const publicEnvironment = request.publicEnvironment; if (!publicEnvironment || typeof publicEnvironment !== 'object' || Array.isArray(publicEnvironment) || Object.entries(publicEnvironment).some(([keyArg, valueArg]) => ( !appStorePublicEnvKeyRegex.test(keyArg) || typeof valueArg !== 'string' ))) { errors.push('install publicEnvironment must be a canonical string map'); } const secretInputs = Array.isArray(request.secretInputs) ? request.secretInputs : []; if (!Array.isArray(request.secretInputs) || secretInputs.length > 256) { errors.push('install secretInputs must be a bounded array'); } const inputKeys: string[] = []; const inputsByKey = new Map>(); const declarations = Array.isArray(rawDeclarations) && rawDeclarations.length <= 256 ? rawDeclarations.filter((entryArg): entryArg is TAppStoreEnvironmentDeclaration => ( validateAppStoreEnvironmentDeclarations([entryArg]).length === 0 )) : []; const publicDeclarations = new Map(declarations .filter((entryArg): entryArg is IAppStorePublicEnvironmentDeclaration => entryArg.secret !== true) .map((entryArg) => [entryArg.key, entryArg])); const secretDeclarations = new Map(declarations .filter((entryArg): entryArg is IAppStoreSecretEnvironmentDeclaration => entryArg.secret === true) .map((entryArg) => [entryArg.key, entryArg])); for (const [indexArg, inputArg] of secretInputs.entries()) { if (!inputArg || typeof inputArg !== 'object' || Array.isArray(inputArg)) { errors.push(`install secretInputs[${indexArg}] must be an object`); continue; } const input = inputArg as Record; if (JSON.stringify(Object.keys(input).sort()) !== JSON.stringify(['key', 'valueInput'])) { errors.push(`install secretInputs[${indexArg}] must use its exact schema`); } if (typeof input.key !== 'string' || !appStoreSecretKeyRegex.test(input.key)) { errors.push(`install secretInputs[${indexArg}].key must be canonical`); continue; } inputKeys.push(input.key); inputsByKey.set(input.key, input); const declaration = secretDeclarations.get(input.key); const canBuildContext = declaration && typeof request.mutationId === 'string' && appStoreMutationIdRegex.test(request.mutationId) && typeof request.appId === 'string' && Boolean(request.appId.trim()) && typeof request.version === 'string' && Boolean(request.version.trim()) && typeof request.serviceId === 'string' && Boolean(request.serviceId.trim()); const expectedContext = canBuildContext ? createAppStoreInstallSecretEnvelopeContext({ mutationId: request.mutationId as string, appId: request.appId as string, appVersion: request.version as string, serviceId: request.serviceId as string, key: input.key, delivery: declaration.delivery, generation: declaration.generate, }) : new Uint8Array(); errors.push(...(await validateSecretValueInputForRecipient( input.valueInput, activeIngressRecipientArg, expectedContext, )) .map((errorArg) => `install secretInputs[${indexArg}].${errorArg}`)); } if (new Set(inputKeys).size !== inputKeys.length) { errors.push('install secretInputs keys must be unique'); } for (const key of Object.keys((publicEnvironment || {}) as Record)) { if (!publicDeclarations.has(key)) errors.push(`install public key '${key}' is not declared public`); if (inputsByKey.has(key)) errors.push(`install key '${key}' appears in both channels`); } for (const [key, input] of inputsByKey) { const declaration = secretDeclarations.get(key); if (!declaration) { errors.push(`install secret key '${key}' is not declared secret`); continue; } const valueInput = input.valueInput as TSecretValueInput; if (valueInput?.mode === 'generated' && (!declaration.generate || declaration.generate.encoding !== valueInput.encoding || declaration.generate.bytes !== valueInput.bytes)) { errors.push(`install secret key '${key}' generation does not match its declaration`); } } for (const declaration of publicDeclarations.values()) { if (declaration.required && declaration.value === undefined && !Object.hasOwn((publicEnvironment || {}) as object, declaration.key)) { errors.push(`install required public key '${declaration.key}' is missing`); } } for (const declaration of secretDeclarations.values()) { if (declaration.required && !declaration.generate && !inputsByKey.has(declaration.key)) { errors.push(`install required secret key '${declaration.key}' is missing`); } } return errors; }; export interface IUpgradeableAppStoreService { serviceId?: string; serviceName: string; appTemplateId: string; currentVersion: string; latestVersion: string; hasMigration: boolean; }