fix(appstore): handle App Store backend failures and empty RPC responses

This commit is contained in:
2026-05-27 21:32:32 +00:00
parent 9bac0a5f71
commit 78d7479b4a
6 changed files with 113 additions and 30 deletions
+9
View File
@@ -2,6 +2,15 @@
## Pending ## 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 ## 2026-05-26 - 6.4.0
+2 -2
View File
@@ -34,7 +34,7 @@
"@types/node": "^25.9.1" "@types/node": "^25.9.1"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "3.3.1", "@api.global/typedrequest": "3.3.2",
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6", "@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.3", "@api.global/typedsocket": "^4.1.3",
@@ -78,7 +78,7 @@
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@push.rocks/webjwt": "^1.0.10", "@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/appstore": "^0.2.0",
"@serve.zone/interfaces": "^6.2.0", "@serve.zone/interfaces": "^6.2.0",
"@tsclass/tsclass": "^9.5.1" "@tsclass/tsclass": "^9.5.1"
+17 -17
View File
@@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@api.global/typedrequest': '@api.global/typedrequest':
specifier: 3.3.1 specifier: 3.3.2
version: 3.3.1 version: 3.3.2
'@api.global/typedrequest-interfaces': '@api.global/typedrequest-interfaces':
specifier: ^3.0.19 specifier: ^3.0.19
version: 3.0.19 version: 3.0.19
@@ -141,8 +141,8 @@ importers:
specifier: ^1.0.10 specifier: ^1.0.10
version: 1.0.10 version: 1.0.10
'@serve.zone/api': '@serve.zone/api':
specifier: ^5.3.8 specifier: ^5.3.9
version: 5.3.8(@push.rocks/smartserve@2.0.4) version: 5.3.9(@push.rocks/smartserve@2.0.4)
'@serve.zone/appstore': '@serve.zone/appstore':
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0 version: 0.2.0
@@ -262,8 +262,8 @@ packages:
'@api.global/typedrequest-interfaces@3.0.19': '@api.global/typedrequest-interfaces@3.0.19':
resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==} resolution: {integrity: sha512-uuHUXJeOy/inWSDrwD0Cwax2rovpxYllDhM2RWh+6mVpQuNmZ3uw6IVg6dA2G1rOe24Ebs+Y9SzEogo+jYN7vw==}
'@api.global/typedrequest@3.3.1': '@api.global/typedrequest@3.3.2':
resolution: {integrity: sha512-uJ8uGS7T4OvnpvKlc1T6ML/CHOGKZIrgRFYYxnPKho2SZGnBFEfazWKshxlgqPsiWMZDFwX9i8c8sp+l3AGI2w==} resolution: {integrity: sha512-a48z7i9UaP48ru/LzDwPBENHOzn8maHW61rh5g3yGvnIkSWgGGPSGWFDrB44O6jE+2tTr0twh1B+zzNqI4hlIA==}
'@api.global/typedserver@8.4.6': '@api.global/typedserver@8.4.6':
resolution: {integrity: sha512-kSzjzM0TenzRL73rmDiwsJR/SFJ3nPI7zFC9KWxO7nIhyMo5wgO7UMVCpjXrTYMK6c4HwbhBxEPIJb4prqakww==} resolution: {integrity: sha512-kSzjzM0TenzRL73rmDiwsJR/SFJ3nPI7zFC9KWxO7nIhyMo5wgO7UMVCpjXrTYMK6c4HwbhBxEPIJb4prqakww==}
@@ -1829,8 +1829,8 @@ packages:
'@sec-ant/readable-stream@0.4.1': '@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@serve.zone/api@5.3.8': '@serve.zone/api@5.3.9':
resolution: {integrity: sha512-k3IU4mcHuk5pKB+X7rhYWGK+j5hyyDzFoqR3ytzG1iidvgDEIIToQJq+mB3E1v6X1+tI3WyYUaMN/TaZRz0l0w==} resolution: {integrity: sha512-H5T5jPhUrlZFVZLJif8HMKek1dSJ5gzWrj3cDaGj1XXfi/Ca4IJfM9qMwlIJ2CB5SLGl0Y2SlFW5wQJ8N9X9jA==}
'@serve.zone/appstore@0.2.0': '@serve.zone/appstore@0.2.0':
resolution: {integrity: sha512-qt2LVaRpzfJdUywllm+F0njwnN3aHc2aZHEcjc9REn1VDT47UuUEGaKkfNiosGK0GJqb1hPI/GwyuGMe4H4q7w==} resolution: {integrity: sha512-qt2LVaRpzfJdUywllm+F0njwnN3aHc2aZHEcjc9REn1VDT47UuUEGaKkfNiosGK0GJqb1hPI/GwyuGMe4H4q7w==}
@@ -4643,7 +4643,7 @@ snapshots:
'@api.global/typedrequest-interfaces@3.0.19': {} '@api.global/typedrequest-interfaces@3.0.19': {}
'@api.global/typedrequest@3.3.1': '@api.global/typedrequest@3.3.2':
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/isounique': 1.0.5 '@push.rocks/isounique': 1.0.5
@@ -4657,7 +4657,7 @@ snapshots:
'@api.global/typedserver@8.4.6(@tiptap/pm@2.27.2)': '@api.global/typedserver@8.4.6(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@api.global/typedrequest': 3.3.1 '@api.global/typedrequest': 3.3.2
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4) '@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
'@cloudflare/workers-types': 4.20260507.1 '@cloudflare/workers-types': 4.20260507.1
@@ -4703,7 +4703,7 @@ snapshots:
'@api.global/typedsocket@4.1.3(@push.rocks/smartserve@2.0.4)': '@api.global/typedsocket@4.1.3(@push.rocks/smartserve@2.0.4)':
dependencies: dependencies:
'@api.global/typedrequest': 3.3.1 '@api.global/typedrequest': 3.3.2
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/isohash': 2.0.1 '@push.rocks/isohash': 2.0.1
'@push.rocks/smartdelay': 3.1.0 '@push.rocks/smartdelay': 3.1.0
@@ -5270,14 +5270,14 @@ snapshots:
'@design.estate/dees-comms@1.0.30': '@design.estate/dees-comms@1.0.30':
dependencies: dependencies:
'@api.global/typedrequest': 3.3.1 '@api.global/typedrequest': 3.3.2
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartdelay': 3.1.0 '@push.rocks/smartdelay': 3.1.0
broadcast-channel: 7.3.0 broadcast-channel: 7.3.0
'@design.estate/dees-domtools@2.5.6': '@design.estate/dees-domtools@2.5.6':
dependencies: dependencies:
'@api.global/typedrequest': 3.3.1 '@api.global/typedrequest': 3.3.2
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.1 '@push.rocks/lik': 6.4.1
'@push.rocks/smartdelay': 3.1.0 '@push.rocks/smartdelay': 3.1.0
@@ -6435,7 +6435,7 @@ snapshots:
'@push.rocks/qenv@6.1.4': '@push.rocks/qenv@6.1.4':
dependencies: dependencies:
'@api.global/typedrequest': 3.3.1 '@api.global/typedrequest': 3.3.2
'@configvault.io/interfaces': 1.0.17 '@configvault.io/interfaces': 1.0.17
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
@@ -7039,7 +7039,7 @@ snapshots:
'@push.rocks/smartserve@2.0.4': '@push.rocks/smartserve@2.0.4':
dependencies: dependencies:
'@api.global/typedrequest': 3.3.1 '@api.global/typedrequest': 3.3.2
'@cfworker/json-schema': 4.1.1 '@cfworker/json-schema': 4.1.1
'@push.rocks/lik': 6.4.1 '@push.rocks/lik': 6.4.1
'@push.rocks/smartenv': 6.1.0 '@push.rocks/smartenv': 6.1.0
@@ -7348,9 +7348,9 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {} '@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: dependencies:
'@api.global/typedrequest': 3.3.1 '@api.global/typedrequest': 3.3.2
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4) '@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
'@push.rocks/smartexpect': 2.5.0 '@push.rocks/smartexpect': 2.5.0
+1
View File
@@ -1,4 +1,5 @@
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- '@api.global/typedrequest'
- '@serve.zone/api' - '@serve.zone/api'
- '@serve.zone/appstore' - '@serve.zone/appstore'
- '@serve.zone/interfaces' - '@serve.zone/interfaces'
+61 -7
View File
@@ -84,13 +84,53 @@ export class CloudlyAppStoreManager {
public async start() {} public async start() {}
public async stop() {} 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() { private registerHandlers() {
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreTemplates>( new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreTemplates>(
'getAppStoreTemplates', 'getAppStoreTemplates',
async (dataArg) => { async (dataArg) => {
await this.passAdminIdentity(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', 'getAppStoreConfig',
async (dataArg) => { async (dataArg) => {
await this.passAdminIdentity(dataArg); await this.passAdminIdentity(dataArg);
return { try {
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version), return {
appMeta: await this.getAppMeta(dataArg.appId), 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', 'installAppStoreApp',
async (dataArg) => { async (dataArg) => {
await this.passAdminIdentity(dataArg); await this.passAdminIdentity(dataArg);
const service = await this.installApp(dataArg.install); try {
return { service: await service.createSavableObject() }; 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,
);
}
}, },
), ),
); );
+23 -4
View File
@@ -977,9 +977,12 @@ export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction(
async (statePartArg) => { async (statePartArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates'); const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates');
const response = await request.fire({ identity: getIdentityForRequest() }); const response = await request.fire({ identity: getIdentityForRequest() });
if (!response?.apps) {
throw new Error('The App Store returned an empty template response. Please retry.');
}
return { return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), ...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
apps: response.apps || [], apps: response.apps,
}; };
}, },
); );
@@ -988,9 +991,12 @@ export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAc
async (statePartArg) => { async (statePartArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getUpgradeableAppStoreServices'); const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getUpgradeableAppStoreServices');
const response = await request.fire({ identity: getIdentityForRequest() }); 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 { return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), ...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
upgradeableServices: response.services || [], upgradeableServices: response.services,
}; };
}, },
); );
@@ -999,9 +1005,12 @@ export const fetchAppStoreUpgradeOperationsAction = appStoreStatePart.createActi
async (statePartArg) => { async (statePartArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradeOperations'); const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradeOperations');
const response = await request.fire({ identity: getIdentityForRequest() }); 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 { return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), ...(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) => { export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreConfig'); const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreConfig');
return await request.fire({ const response = await request.fire({
identity: getIdentityForRequest(), identity: getIdentityForRequest(),
appId: appIdArg, appId: appIdArg,
version: versionArg, version: versionArg,
@@ -1035,6 +1044,10 @@ export const getAppStoreConfig = async (appIdArg: string, versionArg: string) =>
config: plugins.interfaces.appstore.IAppStoreVersionConfig; config: plugins.interfaces.appstore.IAppStoreVersionConfig;
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta; 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) => { export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVersionArg?: string) => {
@@ -1044,6 +1057,9 @@ export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVers
serviceId: serviceIdArg, serviceId: serviceIdArg,
targetVersion: targetVersionArg, targetVersion: targetVersionArg,
}); });
if (!response?.preview) {
throw new Error('The App Store returned an empty upgrade preview response. Please retry.');
}
return response.preview as IAppStoreUpgradePreview; return response.preview as IAppStoreUpgradePreview;
}; };
@@ -1053,5 +1069,8 @@ export const installAppStoreApp = async (installArg: plugins.interfaces.appstore
identity: getIdentityForRequest(), identity: getIdentityForRequest(),
install: installArg, 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; return response.service as plugins.interfaces.data.IService;
}; };