diff --git a/changelog.md b/changelog.md index 115acc7..2c4bbeb 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,15 @@ ## Pending +### Fixes + +- return safe App Store backend errors for template, config, and install failures +- guard App Store client actions against empty typed RPC responses +- bump `@api.global/typedrequest` to `3.3.2` and `@serve.zone/api` to `^5.3.9` +- handle App Store backend failures and empty RPC responses (appstore) + - return sanitized App Store backend errors for template, config, and install failures + - validate App Store typed RPC responses before updating client state or returning results + - bump typedrequest and serve.zone API dependencies ## 2026-05-26 - 6.4.0 diff --git a/package.json b/package.json index 6d4cdeb..6e6b5ee 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@types/node": "^25.9.1" }, "dependencies": { - "@api.global/typedrequest": "3.3.1", + "@api.global/typedrequest": "3.3.2", "@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedserver": "^8.4.6", "@api.global/typedsocket": "^4.1.3", @@ -78,7 +78,7 @@ "@push.rocks/smartunique": "^3.0.9", "@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/webjwt": "^1.0.10", - "@serve.zone/api": "^5.3.8", + "@serve.zone/api": "^5.3.9", "@serve.zone/appstore": "^0.2.0", "@serve.zone/interfaces": "^6.2.0", "@tsclass/tsclass": "^9.5.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05c3437..a583990 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@api.global/typedrequest': - specifier: 3.3.1 - version: 3.3.1 + specifier: 3.3.2 + version: 3.3.2 '@api.global/typedrequest-interfaces': specifier: ^3.0.19 version: 3.0.19 @@ -141,8 +141,8 @@ importers: specifier: ^1.0.10 version: 1.0.10 '@serve.zone/api': - specifier: ^5.3.8 - version: 5.3.8(@push.rocks/smartserve@2.0.4) + specifier: ^5.3.9 + version: 5.3.9(@push.rocks/smartserve@2.0.4) '@serve.zone/appstore': specifier: ^0.2.0 version: 0.2.0 @@ -262,8 +262,8 @@ packages: '@api.global/typedrequest-interfaces@3.0.19': resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==} - '@api.global/typedrequest@3.3.1': - resolution: {integrity: sha512-uJ8uGS7T4OvnpvKlc1T6ML/CHOGKZIrgRFYYxnPKho2SZGnBFEfazWKshxlgqPsiWMZDFwX9i8c8sp+l3AGI2w==} + '@api.global/typedrequest@3.3.2': + resolution: {integrity: sha512-a48z7i9UaP48ru/LzDwPBENHOzn8maHW61rh5g3yGvnIkSWgGGPSGWFDrB44O6jE+2tTr0twh1B+zzNqI4hlIA==} '@api.global/typedserver@8.4.6': resolution: {integrity: sha512-kSzjzM0TenzRL73rmDiwsJR/SFJ3nPI7zFC9KWxO7nIhyMo5wgO7UMVCpjXrTYMK6c4HwbhBxEPIJb4prqakww==} @@ -1829,8 +1829,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@serve.zone/api@5.3.8': - resolution: {integrity: sha512-k3IU4mcHuk5pKB+X7rhYWGK+j5hyyDzFoqR3ytzG1iidvgDEIIToQJq+mB3E1v6X1+tI3WyYUaMN/TaZRz0l0w==} + '@serve.zone/api@5.3.9': + resolution: {integrity: sha512-H5T5jPhUrlZFVZLJif8HMKek1dSJ5gzWrj3cDaGj1XXfi/Ca4IJfM9qMwlIJ2CB5SLGl0Y2SlFW5wQJ8N9X9jA==} '@serve.zone/appstore@0.2.0': resolution: {integrity: sha512-qt2LVaRpzfJdUywllm+F0njwnN3aHc2aZHEcjc9REn1VDT47UuUEGaKkfNiosGK0GJqb1hPI/GwyuGMe4H4q7w==} @@ -4643,7 +4643,7 @@ snapshots: '@api.global/typedrequest-interfaces@3.0.19': {} - '@api.global/typedrequest@3.3.1': + '@api.global/typedrequest@3.3.2': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/isounique': 1.0.5 @@ -4657,7 +4657,7 @@ snapshots: '@api.global/typedserver@8.4.6(@tiptap/pm@2.27.2)': dependencies: - '@api.global/typedrequest': 3.3.1 + '@api.global/typedrequest': 3.3.2 '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4) '@cloudflare/workers-types': 4.20260507.1 @@ -4703,7 +4703,7 @@ snapshots: '@api.global/typedsocket@4.1.3(@push.rocks/smartserve@2.0.4)': dependencies: - '@api.global/typedrequest': 3.3.1 + '@api.global/typedrequest': 3.3.2 '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/isohash': 2.0.1 '@push.rocks/smartdelay': 3.1.0 @@ -5270,14 +5270,14 @@ snapshots: '@design.estate/dees-comms@1.0.30': dependencies: - '@api.global/typedrequest': 3.3.1 + '@api.global/typedrequest': 3.3.2 '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/smartdelay': 3.1.0 broadcast-channel: 7.3.0 '@design.estate/dees-domtools@2.5.6': dependencies: - '@api.global/typedrequest': 3.3.1 + '@api.global/typedrequest': 3.3.2 '@design.estate/dees-comms': 1.0.30 '@push.rocks/lik': 6.4.1 '@push.rocks/smartdelay': 3.1.0 @@ -6435,7 +6435,7 @@ snapshots: '@push.rocks/qenv@6.1.4': dependencies: - '@api.global/typedrequest': 3.3.1 + '@api.global/typedrequest': 3.3.2 '@configvault.io/interfaces': 1.0.17 '@push.rocks/smartlog': 3.2.2 '@push.rocks/smartpath': 6.0.0 @@ -7039,7 +7039,7 @@ snapshots: '@push.rocks/smartserve@2.0.4': dependencies: - '@api.global/typedrequest': 3.3.1 + '@api.global/typedrequest': 3.3.2 '@cfworker/json-schema': 4.1.1 '@push.rocks/lik': 6.4.1 '@push.rocks/smartenv': 6.1.0 @@ -7348,9 +7348,9 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@serve.zone/api@5.3.8(@push.rocks/smartserve@2.0.4)': + '@serve.zone/api@5.3.9(@push.rocks/smartserve@2.0.4)': dependencies: - '@api.global/typedrequest': 3.3.1 + '@api.global/typedrequest': 3.3.2 '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4) '@push.rocks/smartexpect': 2.5.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3d9398a..2f43525 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ minimumReleaseAgeExclude: + - '@api.global/typedrequest' - '@serve.zone/api' - '@serve.zone/appstore' - '@serve.zone/interfaces' diff --git a/ts/manager.appstore/classes.appstoremanager.ts b/ts/manager.appstore/classes.appstoremanager.ts index 3ac2958..a88fcb6 100644 --- a/ts/manager.appstore/classes.appstoremanager.ts +++ b/ts/manager.appstore/classes.appstoremanager.ts @@ -84,13 +84,53 @@ export class CloudlyAppStoreManager { public async start() {} public async stop() {} + private getErrorMessage(errorArg: unknown): string { + if (errorArg instanceof Error) return errorArg.message; + return String(errorArg); + } + + private getSafeAppStoreErrorMessage(errorArg: unknown): string { + const message = this.getErrorMessage(errorArg); + const lowerMessage = message.toLowerCase(); + if ( + lowerMessage.includes('fetch') || + lowerMessage.includes('connect') || + lowerMessage.includes('connection refused') || + lowerMessage.includes('network') || + /http \d+/.test(lowerMessage) + ) { + return 'The App Store backend is currently unreachable. Please retry later.'; + } + if ( + lowerMessage.includes('domain is required') || + lowerMessage.includes('missing required app env var') || + lowerMessage.includes('unsupported platform requirement') || + lowerMessage.includes('published port') || + lowerMessage.includes('app requires cloudly') + ) { + return message; + } + return 'The App Store request failed. Please retry later.'; + } + + private createSafeAppStoreTypedError(actionArg: string, errorArg: unknown): plugins.typedrequest.TypedResponseError { + console.warn(`${actionArg}: ${this.getErrorMessage(errorArg)}`); + return new plugins.typedrequest.TypedResponseError( + `${actionArg}: ${this.getSafeAppStoreErrorMessage(errorArg)}`, + ); + } + private registerHandlers() { this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreTemplates', async (dataArg) => { await this.passAdminIdentity(dataArg); - return { apps: await this.getApps() }; + try { + return { apps: await this.getApps() }; + } catch (error) { + throw this.createSafeAppStoreTypedError('Could not load App Store templates', error); + } }, ), ); @@ -100,10 +140,17 @@ export class CloudlyAppStoreManager { 'getAppStoreConfig', async (dataArg) => { await this.passAdminIdentity(dataArg); - return { - config: await this.getAppVersionConfig(dataArg.appId, dataArg.version), - appMeta: await this.getAppMeta(dataArg.appId), - }; + try { + return { + config: await this.getAppVersionConfig(dataArg.appId, dataArg.version), + appMeta: await this.getAppMeta(dataArg.appId), + }; + } catch (error) { + throw this.createSafeAppStoreTypedError( + `Could not load App Store details for ${dataArg.appId}@${dataArg.version || 'latest'}`, + error, + ); + } }, ), ); @@ -113,8 +160,15 @@ export class CloudlyAppStoreManager { 'installAppStoreApp', async (dataArg) => { await this.passAdminIdentity(dataArg); - const service = await this.installApp(dataArg.install); - return { service: await service.createSavableObject() }; + try { + const service = await this.installApp(dataArg.install); + return { service: await service.createSavableObject() }; + } catch (error) { + throw this.createSafeAppStoreTypedError( + `Could not install App Store app ${dataArg.install?.appId || 'unknown'}`, + error, + ); + } }, ), ); diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index f7733f2..db58892 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -977,9 +977,12 @@ export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction( async (statePartArg) => { const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getAppStoreTemplates'); const response = await request.fire({ identity: getIdentityForRequest() }); + if (!response?.apps) { + throw new Error('The App Store returned an empty template response. Please retry.'); + } return { ...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), - apps: response.apps || [], + apps: response.apps, }; }, ); @@ -988,9 +991,12 @@ export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAc async (statePartArg) => { const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getUpgradeableAppStoreServices'); const response = await request.fire({ identity: getIdentityForRequest() }); + if (!response?.services) { + throw new Error('The App Store returned an empty upgradeable-services response. Please retry.'); + } return { ...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), - upgradeableServices: response.services || [], + upgradeableServices: response.services, }; }, ); @@ -999,9 +1005,12 @@ export const fetchAppStoreUpgradeOperationsAction = appStoreStatePart.createActi async (statePartArg) => { const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getAppStoreUpgradeOperations'); const response = await request.fire({ identity: getIdentityForRequest() }); + if (!response?.operations) { + throw new Error('The App Store returned an empty upgrade-operations response. Please retry.'); + } return { ...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), - upgradeOperations: response.operations || [], + upgradeOperations: response.operations, }; }, ); @@ -1027,7 +1036,7 @@ export const startAppStoreServiceUpgradeAction = appStoreStatePart.createAction< export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => { const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getAppStoreConfig'); - return await request.fire({ + const response = await request.fire({ identity: getIdentityForRequest(), appId: appIdArg, version: versionArg, @@ -1035,6 +1044,10 @@ export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => config: plugins.interfaces.appstore.IAppStoreVersionConfig; appMeta: plugins.interfaces.appstore.IAppStoreAppMeta; }; + if (!response?.config || !response?.appMeta) { + throw new Error('The App Store returned an empty config response. Please retry.'); + } + return response; }; export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVersionArg?: string) => { @@ -1044,6 +1057,9 @@ export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVers serviceId: serviceIdArg, targetVersion: targetVersionArg, }); + if (!response?.preview) { + throw new Error('The App Store returned an empty upgrade preview response. Please retry.'); + } return response.preview as IAppStoreUpgradePreview; }; @@ -1053,5 +1069,8 @@ export const installAppStoreApp = async (installArg: plugins.interfaces.appstore identity: getIdentityForRequest(), install: installArg, }); + if (!response?.service) { + throw new Error('The App Store returned an empty install response. Please retry.'); + } return response.service as plugins.interfaces.data.IService; };