Compare commits

..

6 Commits

Author SHA1 Message Date
jkunz 05560c9db9 v6.0.0
Docker (tags) / release (push) Failing after 1s
2026-05-25 03:03:25 +00:00
jkunz 50e69b095c feat(appstore): use shared resolver 2026-05-25 03:03:03 +00:00
jkunz d5445609a0 v5.9.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 13:15:16 +00:00
jkunz fd7c7b4313 fix(cloudly): invalidate expired dashboard sessions 2026-05-24 13:14:49 +00:00
jkunz 057af996aa chore(cloudly): consume released Spark interfaces 2026-05-24 12:54:09 +00:00
jkunz 6565c44c29 feat(cloudly): accept Spark node heartbeats 2026-05-24 12:47:15 +00:00
19 changed files with 710 additions and 199 deletions
+27
View File
@@ -3,6 +3,33 @@
## Pending
## 2026-05-25 - 6.0.0
### Breaking Changes
- switch App Store APIs to the shared appstore client
- Replaces Cloudly App Store manager naming and request methods with App Store naming
- Uses `@serve.zone/appstore` for app metadata parsing and source resolution
- Adds `servezone.appstore.json` as Cloudly's source-owned App Store manifest
## 2026-05-24 - 5.9.0
### Features
- accept Spark node heartbeats
- Adds a Spark heartbeat HTTP endpoint for cluster nodes
- Stores Spark metrics and runtime info on cluster node records
- Extends jump onboarding with per-node Spark telemetry credentials
### Fixes
- invalidate expired dashboard sessions and return admins to login
### Maintenance
- refresh release tooling dependencies
- update `@serve.zone/interfaces` to the Spark telemetry contract release
## 2026-05-24 - 5.8.2
- update Cloudly to consume the released Jump Code API client
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/cloudly",
"version": "5.8.2",
"version": "6.0.0",
"private": true,
"description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
"type": "module",
@@ -23,15 +23,15 @@
"docs": "tsdoc aidoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdoc": "^2.0.5",
"@git.zone/tsdocker": "^2.2.6",
"@git.zone/tsdocker": "^2.3.0",
"@git.zone/tspublish": "^1.11.7",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@push.rocks/smartnetwork": "^4.7.1",
"@types/node": "^25.6.2"
"@types/node": "^25.8.0"
},
"dependencies": {
"@api.global/typedrequest": "3.3.1",
@@ -79,7 +79,8 @@
"@push.rocks/taskbuffer": "^8.0.2",
"@push.rocks/webjwt": "^1.0.10",
"@serve.zone/api": "^5.3.8",
"@serve.zone/interfaces": "^5.9.0",
"@serve.zone/appstore": "^0.2.0",
"@serve.zone/interfaces": "^6.0.0",
"@tsclass/tsclass": "^9.5.1"
},
"files": [
+50 -19
View File
@@ -143,16 +143,19 @@ importers:
'@serve.zone/api':
specifier: ^5.3.8
version: 5.3.8(@push.rocks/smartserve@2.0.4)
'@serve.zone/appstore':
specifier: ^0.2.0
version: 0.2.0
'@serve.zone/interfaces':
specifier: ^5.9.0
version: 5.9.0
specifier: ^6.0.0
version: 6.0.0
'@tsclass/tsclass':
specifier: ^9.5.1
version: 9.5.1
devDependencies:
'@git.zone/tsbuild':
specifier: ^4.4.0
version: 4.4.0
specifier: ^4.4.1
version: 4.4.1
'@git.zone/tsbundle':
specifier: ^2.10.4
version: 2.10.4
@@ -160,8 +163,8 @@ importers:
specifier: ^2.0.5
version: 2.0.5(ws@8.20.0)(zod@4.4.3)
'@git.zone/tsdocker':
specifier: ^2.2.6
version: 2.2.6
specifier: ^2.3.0
version: 2.3.0
'@git.zone/tspublish':
specifier: ^1.11.7
version: 1.11.7
@@ -175,8 +178,8 @@ importers:
specifier: ^4.7.1
version: 4.7.1
'@types/node':
specifier: ^25.6.2
version: 25.6.2
specifier: ^25.8.0
version: 25.8.0
packages:
@@ -831,8 +834,8 @@ packages:
'@gerrit0/mini-shiki@3.23.0':
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
'@git.zone/tsbuild@4.4.0':
resolution: {integrity: sha512-98igHfppi6blFYDyzNukNkj4FUO5ZlyXEaSyJh8vCkkZM8SyAgfZj+NUWA1D1iaPXE58UvK1Pt/o8p8iI9UHHw==}
'@git.zone/tsbuild@4.4.1':
resolution: {integrity: sha512-usxx8BBQsAypxjFOfd1GEV9pL9EUshRKktXtRWHMDByb6ps83+PdUIb3D7O+nkkBp4C9PXo3cfbsR4Asvo33CA==}
hasBin: true
'@git.zone/tsbundle@2.10.1':
@@ -847,8 +850,8 @@ packages:
resolution: {integrity: sha512-s0Jbq9q1lvPppaIsLRr0VJR5lJn9bBzSr4POssXHKFJlVXRU5UeefR7sRERXNYz45FUCXLn+PLAB786PKEAKXg==}
hasBin: true
'@git.zone/tsdocker@2.2.6':
resolution: {integrity: sha512-vF0QT5od+t7UyWT8dQt6grybAiVx8EhpH6OZoMsleOrAyLMHEcfAKaPfTELXFnF5A+GPhAree+3KpRGyp5cGCg==}
'@git.zone/tsdocker@2.3.0':
resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==}
hasBin: true
'@git.zone/tspublish@1.11.6':
@@ -2030,8 +2033,14 @@ packages:
'@serve.zone/api@5.3.8':
resolution: {integrity: sha512-k3IU4mcHuk5pKB+X7rhYWGK+j5hyyDzFoqR3ytzG1iidvgDEIIToQJq+mB3E1v6X1+tI3WyYUaMN/TaZRz0l0w==}
'@serve.zone/interfaces@5.9.0':
resolution: {integrity: sha512-XMXyTXTMcB8AX6zYOMO+Jt5bOv9ujyXj5miE6lrgyT8g+eJ/I6sUFqVNUKJ3LiMk/yFWsPln7HtZeZKDEhaCwQ==}
'@serve.zone/appstore@0.2.0':
resolution: {integrity: sha512-qt2LVaRpzfJdUywllm+F0njwnN3aHc2aZHEcjc9REn1VDT47UuUEGaKkfNiosGK0GJqb1hPI/GwyuGMe4H4q7w==}
'@serve.zone/interfaces@5.10.0':
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
'@serve.zone/interfaces@6.0.0':
resolution: {integrity: sha512-nCidhOH0XlX+7e6xaJDq6fwnwaWasB/4w2LHkV7A96G+m+7EXZqbbaKSboUlaiGDly0dWNajk2FrYFo64ZucPA==}
'@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
@@ -2509,6 +2518,9 @@ packages:
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
'@types/node@25.8.0':
resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==}
'@types/randomatic@3.1.5':
resolution: {integrity: sha512-VCwCTw6qh1pRRw+5rNTAwqPmf6A+hdrkdM7dBpZVmhl7g+em3ONXlYK/bWPVKqVGMWgP0d1bog8Vc/X6zRwRRQ==}
@@ -4589,6 +4601,9 @@ packages:
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -5729,9 +5744,9 @@ snapshots:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@git.zone/tsbuild@4.4.0':
'@git.zone/tsbuild@4.4.1':
dependencies:
'@git.zone/tspublish': 1.11.6
'@git.zone/tspublish': 1.11.7
'@push.rocks/early': 4.0.4
'@push.rocks/smartcli': 4.0.21
'@push.rocks/smartdelay': 3.1.0
@@ -5841,7 +5856,7 @@ snapshots:
- ws
- zod
'@git.zone/tsdocker@2.2.6':
'@git.zone/tsdocker@2.3.0':
dependencies:
'@push.rocks/lik': 6.4.1
'@push.rocks/projectinfo': 5.1.0
@@ -7765,12 +7780,22 @@ snapshots:
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstream': 3.4.2
'@serve.zone/interfaces': 5.9.0
'@serve.zone/interfaces': 5.10.0
'@tsclass/tsclass': 9.5.1
transitivePeerDependencies:
- '@push.rocks/smartserve'
'@serve.zone/interfaces@5.9.0':
'@serve.zone/appstore@0.2.0':
dependencies:
'@serve.zone/interfaces': 6.0.0
'@serve.zone/interfaces@5.10.0':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.5.1
'@serve.zone/interfaces@6.0.0':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2
@@ -8398,6 +8423,10 @@ snapshots:
dependencies:
undici-types: 7.19.2
'@types/node@25.8.0':
dependencies:
undici-types: 7.24.6
'@types/randomatic@3.1.5': {}
'@types/relateurl@0.2.33': {}
@@ -10883,6 +10912,8 @@ snapshots:
undici-types@7.19.2: {}
undici-types@7.24.6: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
+1
View File
@@ -1,5 +1,6 @@
minimumReleaseAgeExclude:
- '@serve.zone/api'
- '@serve.zone/appstore'
- '@serve.zone/interfaces'
allowBuilds:
+129
View File
@@ -0,0 +1,129 @@
{
"schemaVersion": 1,
"app": {
"id": "cloudly",
"name": "Cloudly",
"description": "Multi-node serve.zone control plane for clusters, workload services, domains, and deployments.",
"category": "Dev Tools",
"iconName": "server",
"tags": ["serve.zone", "control-plane", "clusters", "deployments"],
"maintainer": "serve.zone",
"links": {
"Source": "https://code.foss.global/serve.zone/cloudly",
"Docs": "https://serve.zone"
}
},
"latestVersion": "latest",
"source": {
"type": "dockerImage",
"image": "code.foss.global/serve.zone/cloudly:latest",
"tracking": "digest"
},
"runtime": {
"image": "code.foss.global/serve.zone/cloudly:latest",
"port": 80,
"envVars": [
{
"key": "SERVEZONE_ENVIRONMENT",
"value": "production",
"description": "Cloudly runtime environment.",
"required": true
},
{
"key": "SERVEZONE_URL",
"value": "${SERVICE_DOMAIN}",
"description": "Public Cloudly hostname without protocol.",
"required": true
},
{
"key": "SERVEZONE_PORT",
"value": "80",
"description": "Internal Cloudly HTTP port inside the container.",
"required": true
},
{
"key": "SERVEZONE_SSLMODE",
"value": "external",
"description": "Use external TLS termination through Onebox or dcrouter.",
"required": true
},
{
"key": "SERVEZONE_ADMINACCOUNT",
"value": "",
"description": "Initial admin account in username:password format. Only used when Cloudly has no human users yet.",
"required": true
},
{
"key": "MONGODB_URL",
"value": "${MONGODB_URI}",
"description": "Authenticated MongoDB connection URL provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_NAME",
"value": "${MONGODB_DATABASE}",
"description": "MongoDB database name provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_USER",
"value": "${MONGODB_USERNAME}",
"description": "MongoDB username provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_PASS",
"value": "${MONGODB_PASSWORD}",
"description": "MongoDB password provisioned by Onebox.",
"required": true
},
{
"key": "S3_ENDPOINT",
"value": "onebox-minio",
"description": "S3 endpoint host for the MinIO service provisioned by Onebox.",
"required": true
},
{
"key": "S3_PORT",
"value": "9000",
"description": "S3 endpoint port for the MinIO service provisioned by Onebox.",
"required": true
},
{
"key": "S3_USESSL",
"value": "false",
"description": "Use plain HTTP for internal MinIO traffic on the Onebox network.",
"required": true
},
{
"key": "S3_BUCKET",
"value": "${S3_BUCKET}",
"description": "S3 bucket provisioned by Onebox for Cloudly's registry.",
"required": true
},
{
"key": "S3_ACCESSKEY",
"value": "${S3_ACCESS_KEY}",
"description": "S3 access key provisioned by Onebox.",
"required": true
},
{
"key": "S3_SECRETKEY",
"value": "${S3_SECRET_KEY}",
"description": "S3 secret key provisioned by Onebox.",
"required": true
}
],
"platformRequirements": {
"mongodb": true,
"s3": true
},
"minOneboxVersion": "1.24.2",
"backupBeforeUpgrade": true,
"healthCheck": {
"path": "/status",
"port": 80,
"expectedStatus": 200
}
}
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.8.2',
version: '6.0.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+5 -5
View File
@@ -34,7 +34,7 @@ import { CloudlySettingsManager } from './manager.settings/classes.settingsmanag
import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js';
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
import { CloudlyAppCatalogManager } from './manager.appcatalog/classes.appcatalogmanager.js';
import { CloudlyAppStoreManager } from './manager.appstore/classes.appstoremanager.js';
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
/**
@@ -81,7 +81,7 @@ export class Cloudly {
public nodeManager: CloudlyNodeManager;
public baremetalManager: CloudlyBaremetalManager;
public baseOsManager: CloudlyBaseOsManager;
public appCatalogManager: CloudlyAppCatalogManager;
public appStoreManager: CloudlyAppStoreManager;
public jumpManager: CloudlyJumpManager;
private readyDeferred = new plugins.smartpromise.Deferred();
@@ -119,7 +119,7 @@ export class Cloudly {
this.backupManager = new CloudlyBackupManager(this);
this.baseOsManager = new CloudlyBaseOsManager(this);
this.secretManager = new CloudlySecretManager(this);
this.appCatalogManager = new CloudlyAppCatalogManager(this);
this.appStoreManager = new CloudlyAppStoreManager(this);
this.nodeManager = new CloudlyNodeManager(this);
this.baremetalManager = new CloudlyBaremetalManager(this);
this.jumpManager = new CloudlyJumpManager(this);
@@ -151,7 +151,7 @@ export class Cloudly {
await this.taskManager.init();
await this.backupManager.start();
await this.baseOsManager.start();
await this.appCatalogManager.start();
await this.appStoreManager.start();
await this.registryManager.start();
await this.domainManager.init();
@@ -186,7 +186,7 @@ export class Cloudly {
await this.backupManager.stop();
await this.baseOsManager.stop();
await this.registryManager.stop();
await this.appCatalogManager.stop();
await this.appStoreManager.stop();
await this.externalRegistryManager.stop();
}
}
+5
View File
@@ -120,6 +120,11 @@ export class CloudlyServer {
'POST',
async (ctx) => this.cloudlyRef.baseOsManager.handleHeartbeatHttpRequest(ctx),
);
this.typedServer.addRoute(
'/spark/v1/nodes/heartbeat',
'POST',
async (ctx) => this.cloudlyRef.nodeManager.handleSparkHeartbeatHttpRequest(ctx),
);
this.typedServer.addRoute(
'/baseos/v1/images/:buildId/download',
'GET',
@@ -5,19 +5,18 @@ import { Service } from '../manager.service/classes.service.js';
import { SecretBundle } from '../manager.secret/classes.secretbundle.js';
import { PlatformBinding } from '../manager.platform/classes.platformbinding.js';
type ICatalogApp = plugins.servezoneInterfaces.appcatalog.ICatalogApp;
type ICatalog = plugins.servezoneInterfaces.appcatalog.ICatalog;
type IAppMeta = plugins.servezoneInterfaces.appcatalog.IAppMeta;
type IAppVersionConfig = plugins.servezoneInterfaces.appcatalog.IAppVersionConfig;
type IInstallOptions = plugins.servezoneInterfaces.appcatalog.IAppInstallRequest;
type IUpgradeableCatalogService = plugins.servezoneInterfaces.appcatalog.IUpgradeableAppService;
type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp;
type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex;
type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta;
type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig;
type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest;
type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService;
export class CloudlyAppCatalogManager {
export class CloudlyAppStoreManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
private catalogCache: ICatalog | null = null;
private lastFetchTime = 0;
private readonly repoBaseUrl = process.env.APPCATALOG_URL || 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
private readonly cacheTtlMs = 5 * 60 * 1000;
private readonly appStoreResolver = new plugins.servezoneAppstore.AppStoreResolver({
baseUrl: process.env.APPSTORE_URL || 'https://code.foss.global/serve.zone/appstore/raw/branch/main',
});
constructor(private cloudlyRef: Cloudly) {
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
@@ -28,88 +27,74 @@ export class CloudlyAppCatalogManager {
public async stop() {}
private registerHandlers() {
const addCatalogListHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreTemplates>(
'getAppStoreTemplates',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { apps: await this.getApps() };
}),
);
};
addCatalogListHandler('getAppCatalogTemplates');
addCatalogListHandler('getAppTemplates');
},
),
);
const addConfigHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreConfig>(
'getAppStoreConfig',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return {
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
appMeta: await this.getAppMeta(dataArg.appId),
};
}),
);
};
addConfigHandler('getAppCatalogConfig');
addConfigHandler('getAppConfig');
},
),
);
const addInstallHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_InstallAppStoreApp>(
'installAppStoreApp',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
const service = await this.installApp(dataArg.install || dataArg);
const service = await this.installApp(dataArg.install);
return { service: await service.createSavableObject() };
}),
);
};
addInstallHandler('installAppCatalogApp');
addInstallHandler('installAppTemplate');
},
),
);
const addUpgradeableHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetUpgradeableAppStoreServices>(
'getUpgradeableAppStoreServices',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { services: await this.getUpgradeableServices() };
}),
);
};
addUpgradeableHandler('getUpgradeableAppCatalogServices');
addUpgradeableHandler('getUpgradeableServices');
return { services: await this.getUpgradeableAppStoreServices() };
},
),
);
}
public async getCatalog(): Promise<ICatalog> {
const now = Date.now();
if (this.catalogCache && now - this.lastFetchTime < this.cacheTtlMs) {
return this.catalogCache;
}
const catalog = await this.fetchJson('catalog.json') as ICatalog;
if (!catalog || !Array.isArray(catalog.apps)) {
throw new Error('Invalid app catalog format');
}
this.catalogCache = catalog;
this.lastFetchTime = now;
return catalog;
public async getAppStore(): Promise<IAppStoreIndex> {
return await this.appStoreResolver.getAppStoreIndex();
}
public async getApps(): Promise<ICatalogApp[]> {
return (await this.getCatalog()).apps;
public async getApps(): Promise<IAppStoreApp[]> {
return await this.appStoreResolver.getApps();
}
public async getAppMeta(appIdArg: string): Promise<IAppMeta> {
return await this.fetchJson(`apps/${appIdArg}/app.json`) as IAppMeta;
public async getAppMeta(appIdArg: string): Promise<IAppStoreAppMeta> {
return await this.appStoreResolver.getAppMeta(appIdArg);
}
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppVersionConfig> {
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppStoreVersionConfig> {
if (!versionArg) {
versionArg = (await this.getAppMeta(appIdArg)).latestVersion;
}
return await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as IAppVersionConfig;
return await this.appStoreResolver.getAppVersionConfig(appIdArg, versionArg);
}
public async getUpgradeableServices(): Promise<IUpgradeableCatalogService[]> {
const catalog = await this.getCatalog();
public async getUpgradeableAppStoreServices(): Promise<IUpgradeableAppStoreService[]> {
const appStore = await this.getAppStore();
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
const upgradeableServices: IUpgradeableCatalogService[] = [];
const upgradeableServices: IUpgradeableAppStoreService[] = [];
for (const service of services) {
const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & {
@@ -119,15 +104,15 @@ export class CloudlyAppCatalogManager {
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
continue;
}
const catalogApp = catalog.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
if (!catalogApp || catalogApp.latestVersion === serviceData.appTemplateVersion) {
const appStoreApp = appStore.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
if (!appStoreApp || appStoreApp.latestVersion === serviceData.appTemplateVersion) {
continue;
}
upgradeableServices.push({
serviceName: serviceData.name,
appTemplateId: serviceData.appTemplateId,
currentVersion: serviceData.appTemplateVersion,
latestVersion: catalogApp.latestVersion,
latestVersion: appStoreApp.latestVersion,
hasMigration: false,
});
}
@@ -135,18 +120,19 @@ export class CloudlyAppCatalogManager {
return upgradeableServices;
}
public async installApp(optionsArg: IInstallOptions): Promise<Service> {
public async installApp(optionsArg: IAppStoreInstallOptions): Promise<Service> {
const appMeta = await this.getAppMeta(optionsArg.appId);
const version = optionsArg.version || appMeta.latestVersion;
const config = await this.getAppVersionConfig(optionsArg.appId, version);
const appStoreVersion = config.appStoreVersion || version;
const webPort = optionsArg.port || config.port;
this.assertSupportedPlatformRequirements(config);
const envVars = this.getCatalogEnvVars(config, optionsArg.envVars || {});
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
}
const image = await this.createCatalogImage(optionsArg.serviceName, config.image, appMeta.description);
const image = await this.createAppStoreImage(optionsArg.serviceName, config.image, appMeta.description);
const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id);
const serviceData = {
name: optionsArg.serviceName,
@@ -155,7 +141,7 @@ export class CloudlyAppCatalogManager {
imageVersion: this.getImageTag(config.image),
deployOnPush: false,
appTemplateId: optionsArg.appId,
appTemplateVersion: version,
appTemplateVersion: appStoreVersion,
environment: envVars,
secretBundleId: secretBundle.id,
additionalSecretBundleIds: [],
@@ -181,11 +167,11 @@ export class CloudlyAppCatalogManager {
return service;
}
private async createCatalogImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
private async createAppStoreImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
const image = new Image();
image.id = await Image.getNewId();
image.data = {
name: `${serviceNameArg}-catalog-image`,
name: `${serviceNameArg}-appstore-image`,
description: descriptionArg,
location: {
internal: false,
@@ -210,8 +196,8 @@ export class CloudlyAppCatalogManager {
const secretBundle = new SecretBundle();
secretBundle.id = plugins.smartunique.shortId(8);
secretBundle.data = {
name: `${serviceNameArg} catalog secrets`,
description: `Generated catalog secret bundle for ${serviceNameArg}`,
name: `${serviceNameArg} appstore secrets`,
description: `Generated appstore secret bundle for ${serviceNameArg}`,
type: 'service',
includedSecretGroupIds: [],
includedTags: [],
@@ -222,7 +208,7 @@ export class CloudlyAppCatalogManager {
return secretBundle;
}
private async createPlatformBindings(serviceArg: Service, configArg: IAppVersionConfig) {
private async createPlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) {
const requirements = configArg.platformRequirements || {};
if (requirements.mongodb) {
await PlatformBinding.upsertBinding({
@@ -244,7 +230,7 @@ export class CloudlyAppCatalogManager {
}
}
private normalizeVolumes(volumesArg: IAppVersionConfig['volumes'] = []) {
private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) {
return volumesArg.map((volumeArg) => {
if (typeof volumeArg === 'string') {
return { mountPath: volumeArg };
@@ -253,7 +239,7 @@ export class CloudlyAppCatalogManager {
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
}
private getCatalogEnvVars(configArg: IAppVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
const envVars: Record<string, string> = {};
const missingRequiredEnvVars: string[] = [];
for (const envVar of configArg.envVars || []) {
@@ -274,12 +260,12 @@ export class CloudlyAppCatalogManager {
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
}
private assertSupportedPlatformRequirements(configArg: IAppVersionConfig) {
private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) {
const unsupported = Object.entries(configArg.platformRequirements || {})
.filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3')
.map(([key]) => key);
if (unsupported.length > 0) {
throw new Error(`Cloudly catalog install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
throw new Error(`Cloudly App Store install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
}
}
@@ -294,13 +280,4 @@ export class CloudlyAppCatalogManager {
this.cloudlyRef.authManager.adminIdentityGuard,
]);
}
private async fetchJson(pathArg: string): Promise<unknown> {
const url = `${this.repoBaseUrl.replace(/\/+$/, '')}/${pathArg}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}
}
+48 -29
View File
@@ -11,6 +11,17 @@ export interface IJwtData {
expiresAt: number;
}
interface IReq_AdminValidateIdentity {
method: 'adminValidateIdentity';
request: {
identity: plugins.servezoneInterfaces.data.IIdentity;
};
response: {
valid: boolean;
reason?: string;
};
}
export class CloudlyAuthManager {
cloudlyRef: Cloudly;
public get db() {
@@ -82,6 +93,16 @@ export class CloudlyAuthManager {
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<IReq_AdminValidateIdentity>('adminValidateIdentity', async (dataArg) => {
const valid = await this.adminIdentityGuard.exec(dataArg).catch(() => false);
return {
valid,
reason: valid ? undefined : 'identity is not valid',
};
}),
);
}
private async bootstrapInitialAdmin() {
@@ -126,28 +147,22 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const expired = jwtData.expiresAt < Date.now();
plugins.smartexpect
.expect(jwtData.status)
.setFailMessage('user not logged in')
.toEqual('loggedIn');
plugins.smartexpect.expect(expired).setFailMessage(`jwt expired`).toBeFalse();
plugins.smartexpect
.expect(dataArg.identity.expiresAt)
.setFailMessage(
`expiresAt >>identity valid until:${dataArg.identity.expiresAt}, but jwt says: ${jwtData.expiresAt}<< has been tampered with`,
)
.toEqual(jwtData.expiresAt);
plugins.smartexpect
.expect(dataArg.identity.userId)
.setFailMessage('userId has been tampered with')
.toEqual(jwtData.userId);
if (expired) {
throw new Error('identity is expired');
try {
const jwt = dataArg.identity?.jwt;
if (!jwt) {
return false;
}
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const expired = jwtData.expiresAt < Date.now();
return (
jwtData.status === 'loggedIn' &&
!expired &&
dataArg.identity.expiresAt === jwtData.expiresAt &&
dataArg.identity.userId === jwtData.userId
);
} catch {
return false;
}
return true;
},
{
failedHint: 'identity is not valid.',
@@ -159,16 +174,17 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]);
const validIdentity = await this.validIdentityGuard.exec(dataArg);
if (!validIdentity) {
return false;
}
const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({ id: jwtData.userId });
const isAdminBool = user.data.role === 'admin';
console.log(`user is admin: ${isAdminBool}`);
return isAdminBool;
return user?.data.role === 'admin';
},
{
failedHint: 'user is not admin.',
failedHint: 'identity is not valid or user is not admin.',
name: 'adminIdentityGuard',
},
);
@@ -177,14 +193,17 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]);
const validIdentity = await this.validIdentityGuard.exec(dataArg);
if (!validIdentity) {
return false;
}
const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({ id: jwtData.userId });
return user.data.role === 'admin' || user.data.role === 'cluster';
return user?.data.role === 'admin' || user?.data.role === 'cluster';
},
{
failedHint: 'user is not admin or cluster.',
failedHint: 'identity is not valid or user is not admin or cluster.',
name: 'adminOrClusterIdentityGuard',
},
);
+7 -1
View File
@@ -15,6 +15,7 @@ interface IClaimJumpCodeResponse {
accepted: boolean;
message?: string;
nodeId?: string;
sparkNodeToken?: string;
cloudlyUrl?: string;
coreflowJumpCode?: string;
}
@@ -148,8 +149,10 @@ export class CloudlyJumpManager {
const nodeId = plugins.smartunique.shortId(8);
const now = Date.now();
const sparkNodeToken = await this.cloudlyRef.authManager.createNewSecureToken();
const node = new this.cloudlyRef.nodeManager.CClusterNode();
node.id = nodeId;
node.sparkNodeTokenHash = this.hashSecret(sparkNodeToken);
node.data = {
clusterId: cluster.id,
nodeType: jumpCodeDoc.data.nodeType,
@@ -178,6 +181,7 @@ export class CloudlyJumpManager {
return {
accepted: true,
nodeId: node.id,
sparkNodeToken,
cloudlyUrl: cluster.data.cloudlyUrl || `${this.getPublicCloudlyUrl()}/`,
coreflowJumpCode,
};
@@ -292,8 +296,10 @@ CLAIM_RESPONSE="$(curl -fsSL -X POST "\${CLAIM_URL}" -H 'content-type: applicati
export CLAIM_RESPONSE
CLOUDLY_URL="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.accepted) { throw new Error(data.message || "Cloudly rejected jump code"); } process.stdout.write(data.cloudlyUrl);')"
COREFLOW_JUMPCODE="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.coreflowJumpCode) { throw new Error("Cloudly did not return a Coreflow jump code"); } process.stdout.write(data.coreflowJumpCode);')"
SPARK_NODE_ID="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.nodeId) { throw new Error("Cloudly did not return a Spark node id"); } process.stdout.write(data.nodeId);')"
SPARK_NODE_TOKEN="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.sparkNodeToken) { throw new Error("Cloudly did not return a Spark node token"); } process.stdout.write(data.sparkNodeToken);')"
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}"
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}" --nodeId="\${SPARK_NODE_ID}" --nodeToken="\${SPARK_NODE_TOKEN}"
echo "Cloudly jump completed. This system is now connected."
`;
+17
View File
@@ -39,6 +39,9 @@ export class ClusterNode extends plugins.smartdata.SmartDataDbDoc<
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.IClusterNode['data'];
@plugins.smartdata.svDb()
public sparkNodeTokenHash?: string;
constructor() {
super();
}
@@ -54,6 +57,20 @@ export class ClusterNode extends plugins.smartdata.SmartDataDbDoc<
await this.save();
}
public async updateSparkHeartbeat(
metricsArg: plugins.servezoneInterfaces.data.IClusterNodeMetrics,
runtimeInfoArg: plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo,
) {
this.data.metrics = metricsArg;
this.data.sparkRuntimeInfo = runtimeInfoArg;
this.data.status = 'online';
this.data.lastHealthCheck = Date.now();
if (typeof runtimeInfoArg.swarmNodeId === 'string' && runtimeInfoArg.swarmNodeId) {
this.data.swarmNodeId = runtimeInfoArg.swarmNodeId;
}
await this.save();
}
public async updateStatus(status: plugins.servezoneInterfaces.data.IClusterNode['data']['status']) {
this.data.status = status;
await this.save();
+123
View File
@@ -4,6 +4,18 @@ import { Cluster } from '../manager.cluster/classes.cluster.js';
import { ClusterNode } from './classes.clusternode.js';
import { CurlFresh } from './classes.curlfresh.js';
interface ISparkHeartbeatRequest {
nodeId?: string;
nodeToken?: string;
metrics?: plugins.servezoneInterfaces.data.IClusterNodeMetrics;
runtimeInfo?: plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo;
}
interface ISparkHeartbeatResponse {
accepted: boolean;
message?: string;
}
export class CloudlyNodeManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
@@ -51,6 +63,69 @@ export class CloudlyNodeManager {
public async stop() {}
public async handleSparkHeartbeatHttpRequest(
ctxArg: plugins.typedserver.IRequestContext,
): Promise<Response> {
try {
const requestData = await this.readJsonBody<ISparkHeartbeatRequest>(ctxArg);
const response = await this.acceptSparkHeartbeat(requestData);
return this.createJsonResponse(200, response);
} catch (error) {
return this.createJsonResponse(400, {
accepted: false,
message: `Spark heartbeat failed: ${(error as Error).message}`,
} satisfies ISparkHeartbeatResponse);
}
}
public async acceptSparkHeartbeat(
requestDataArg: ISparkHeartbeatRequest,
): Promise<ISparkHeartbeatResponse> {
if (!requestDataArg.nodeId) {
return {
accepted: false,
message: 'Spark node id is missing',
};
}
if (!requestDataArg.nodeToken) {
return {
accepted: false,
message: 'Spark node token is missing',
};
}
if (!this.isSparkMetrics(requestDataArg.metrics)) {
return {
accepted: false,
message: 'Spark metrics are missing or invalid',
};
}
if (!this.isSparkRuntimeInfo(requestDataArg.runtimeInfo, requestDataArg.nodeId)) {
return {
accepted: false,
message: 'Spark runtime info is missing or invalid',
};
}
const node = await this.CClusterNode.getInstance({ id: requestDataArg.nodeId });
if (!node) {
return {
accepted: false,
message: 'Spark node is unknown',
};
}
if (node.sparkNodeTokenHash !== this.hashSecret(requestDataArg.nodeToken)) {
return {
accepted: false,
message: 'Spark node token is invalid',
};
}
await node.updateSparkHeartbeat(requestDataArg.metrics, requestDataArg.runtimeInfo);
return {
accepted: true,
};
}
/**
* creates the node infrastructure on hetzner
* ensures that there are exactly the resources that are needed
@@ -133,4 +208,52 @@ export class CloudlyNodeManager {
});
return results;
}
private isSparkMetrics(valueArg: unknown): valueArg is plugins.servezoneInterfaces.data.IClusterNodeMetrics {
if (!valueArg || typeof valueArg !== 'object') {
return false;
}
const metrics = valueArg as Partial<plugins.servezoneInterfaces.data.IClusterNodeMetrics>;
return typeof metrics.cpuUsagePercent === 'number'
&& typeof metrics.memoryUsedMB === 'number'
&& typeof metrics.memoryAvailableMB === 'number'
&& typeof metrics.diskUsedGB === 'number'
&& typeof metrics.diskAvailableGB === 'number'
&& typeof metrics.containerCount === 'number'
&& typeof metrics.timestamp === 'number';
}
private isSparkRuntimeInfo(
valueArg: unknown,
nodeIdArg: string,
): valueArg is plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo {
if (!valueArg || typeof valueArg !== 'object') {
return false;
}
const runtimeInfo = valueArg as Record<string, unknown>;
return runtimeInfo.runtime === 'spark'
&& runtimeInfo.nodeId === nodeIdArg
&& typeof runtimeInfo.checkedAt === 'number';
}
private hashSecret(secretArg: string) {
return plugins.crypto.createHash('sha256').update(secretArg).digest('hex');
}
private async readJsonBody<T>(ctxArg: plugins.typedserver.IRequestContext): Promise<T> {
const bodyString = (await ctxArg.text()).trim();
return bodyString ? JSON.parse(bodyString) as T : {} as T;
}
private createJsonResponse(
statusCodeArg: number,
bodyArg: object,
): Response {
return new Response(JSON.stringify(bodyArg), {
status: statusCodeArg,
headers: {
'Content-Type': 'application/json',
},
});
}
}
+2 -1
View File
@@ -86,5 +86,6 @@ export {
// @servezone scope
import * as servezoneInterfaces from '@serve.zone/interfaces';
import * as servezoneAppstore from '@serve.zone/appstore';
export { servezoneInterfaces };
export { servezoneAppstore, servezoneInterfaces };
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.8.2',
version: '6.0.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+164 -37
View File
@@ -16,6 +16,55 @@ export interface IUiState {
activeSubview: string | null;
}
export interface IDataState {
secretGroups?: plugins.interfaces.data.ISecretGroup[];
secretBundles?: plugins.interfaces.data.ISecretBundle[];
clusters?: plugins.interfaces.data.ICluster[];
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
images?: any[];
services?: plugins.interfaces.data.IService[];
deployments?: plugins.interfaces.data.IDeployment[];
domains?: plugins.interfaces.data.IDomain[];
dnsEntries?: plugins.interfaces.data.IDnsEntry[];
tasks?: any[];
taskExecutions?: plugins.interfaces.data.ITaskExecution[];
mails?: any[];
logs?: any[];
s3?: any[];
dbs?: any[];
backups?: any[];
}
const emptyDataState: IDataState = {
secretGroups: [],
secretBundles: [],
clusters: [],
externalRegistries: [],
images: [],
services: [],
deployments: [],
domains: [],
dnsEntries: [],
tasks: [],
taskExecutions: [],
mails: [],
logs: [],
s3: [],
dbs: [],
backups: [],
};
interface IReq_AdminValidateIdentity {
method: 'adminValidateIdentity';
request: {
identity: plugins.interfaces.data.IIdentity;
};
response: {
valid: boolean;
reason?: string;
};
}
const getInitialView = (): string => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const validViews = ['overview', 'platform', 'runtime', 'registry', 'secrets', 'domains', 'storage', 'logs'];
@@ -49,7 +98,7 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw
}
const newState = {
...currentState,
...(identity ? { identity } : {}),
identity,
};
try {
// Keep shared API client in sync and establish WS for modules using sockets
@@ -67,50 +116,19 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
const currentState = statePartArg.getState() || { identity: null };
try {
apiClient.identity = null;
dataState.setState({ ...emptyDataState });
} catch {}
return {
...currentState,
identity: null,
};
});
export interface IDataState {
secretGroups?: plugins.interfaces.data.ISecretGroup[];
secretBundles?: plugins.interfaces.data.ISecretBundle[];
clusters?: plugins.interfaces.data.ICluster[];
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
images?: any[];
services?: plugins.interfaces.data.IService[];
deployments?: plugins.interfaces.data.IDeployment[];
domains?: plugins.interfaces.data.IDomain[];
dnsEntries?: plugins.interfaces.data.IDnsEntry[];
tasks?: any[];
taskExecutions?: plugins.interfaces.data.ITaskExecution[];
mails?: any[];
logs?: any[];
s3?: any[];
dbs?: any[];
backups?: any[];
}
export const dataState = await appstate.getStatePart<IDataState>(
'data',
{
secretGroups: [],
secretBundles: [],
clusters: [],
externalRegistries: [],
images: [],
services: [],
deployments: [],
domains: [],
dnsEntries: [],
tasks: [],
taskExecutions: [],
mails: [],
logs: [],
s3: [],
dbs: [],
backups: [],
},
{ ...emptyDataState },
'soft'
);
@@ -124,6 +142,115 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({
cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined,
}) as TCloudlyApiClientWithNullableIdentity;
let identityExpiryTimer: number | undefined;
let identityInvalidationRunning = false;
const getErrorText = (errorArg: unknown): string => {
if (!errorArg) return '';
if (typeof errorArg === 'string') return errorArg;
const errorLike = errorArg as { errorText?: string; message?: string; text?: string };
return errorLike.errorText || errorLike.message || errorLike.text || '';
};
const isAuthRejectionText = (errorTextArg: string): boolean => {
const errorText = errorTextArg.toLowerCase();
return [
'identity is not valid',
'jwt expired',
'identity is expired',
'user not logged in',
'has been tampered with',
'invalid jwt',
'invalid signature',
].some((textPart) => errorText.includes(textPart));
};
export const isIdentityExpired = (identityArg: plugins.interfaces.data.IIdentity | null | undefined): boolean => {
return typeof identityArg?.expiresAt === 'number' && identityArg.expiresAt <= Date.now();
};
export const invalidateIdentity = async (reasonArg = 'identity is not valid'): Promise<void> => {
if (identityInvalidationRunning) return;
identityInvalidationRunning = true;
try {
const currentLoginState = loginStatePart.getState() || { identity: null };
if (currentLoginState.identity) {
console.warn(`Cloudly session invalidated: ${reasonArg}`);
}
apiClient.identity = null;
try { await apiClient.typedsocketClient?.setTag('identity', null); } catch {}
loginStatePart.setState({
...currentLoginState,
identity: null,
});
dataState.setState({ ...emptyDataState });
} finally {
identityInvalidationRunning = false;
}
};
export const validateStoredIdentity = async (): Promise<boolean> => {
const identity = loginStatePart.getState()?.identity ?? null;
if (!identity) return false;
if (isIdentityExpired(identity)) {
await invalidateIdentity('identity expired');
return false;
}
const validateIdentityRequest = new plugins.typedrequest.TypedRequest<IReq_AdminValidateIdentity>(
'/typedrequest',
'adminValidateIdentity',
);
try {
const response = await validateIdentityRequest.fire({ identity });
if (!response?.valid) {
await invalidateIdentity(response?.reason || 'identity rejected by server');
return false;
}
} catch (error) {
const errorText = getErrorText(error);
if (isAuthRejectionText(errorText)) {
await invalidateIdentity(errorText);
return false;
}
console.warn('Could not validate stored identity:', error);
}
return !!loginStatePart.getState()?.identity;
};
const scheduleIdentityExpiryTimer = () => {
if (identityExpiryTimer) {
window.clearTimeout(identityExpiryTimer);
identityExpiryTimer = undefined;
}
const identity = loginStatePart.getState()?.identity ?? null;
if (!identity?.expiresAt) return;
const msUntilExpiry = identity.expiresAt - Date.now();
if (msUntilExpiry <= 0) {
void invalidateIdentity('identity expired');
return;
}
identityExpiryTimer = window.setTimeout(() => {
void invalidateIdentity('identity expired');
}, Math.min(msUntilExpiry, 2147483647));
};
plugins.typedrequest.TypedRouter.setGlobalHooks({
onIncomingResponse: (entryArg) => {
if (entryArg.error && isAuthRejectionText(entryArg.error)) {
void invalidateIdentity(entryArg.error);
}
},
});
loginStatePart.select((stateArg) => stateArg?.identity ?? null).subscribe(() => {
scheduleIdentityExpiryTimer();
});
scheduleIdentityExpiryTimer();
// Getting data
export const getAllDataAction = dataState.createAction(async (statePartArg) => {
let currentState = statePartArg.getState() || {};
+50 -3
View File
@@ -169,6 +169,17 @@ export class CloudlyDashboard extends DeesElement {
this.syncAppdashView(uiState.activeView, uiState.activeSubview);
});
this.rxSubscriptions.push(uiSubscription);
const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg?.identity ?? null)
.subscribe((identityArg) => {
const hadIdentity = !!this.identity;
this.identity = identityArg ?? null;
if (!identityArg && hadIdentity) {
void this.switchToLoginContent('Session expired. Please sign in again.');
}
});
this.rxSubscriptions.push(loginSubscription);
}
private syncAppdashView(viewSlug: string, subviewSlug: string | null): void {
@@ -267,9 +278,16 @@ export class CloudlyDashboard extends DeesElement {
const domtools = await this.domtoolsPromise;
const loginState = appstate.loginStatePart.getState();
if (loginState?.identity) {
this.identity = loginState.identity;
const identityValid = await appstate.validateStoredIdentity();
const currentIdentity = appstate.loginStatePart.getState()?.identity ?? null;
if (!identityValid || !currentIdentity) {
await this.switchToLoginContent('Session expired. Please sign in again.');
return;
}
this.identity = currentIdentity;
try {
appstate.apiClient.identity = loginState.identity;
appstate.apiClient.identity = currentIdentity;
if (!appstate.apiClient['typedsocketClient']) {
await appstate.apiClient.start();
}
@@ -301,5 +319,34 @@ export class CloudlyDashboard extends DeesElement {
}
}
private async logout() {}
private async switchToLoginContent(statusMessageArg?: string) {
const simpleLogin = this.shadowRoot?.querySelector('dees-simple-login') as any;
if (!simpleLogin?.shadowRoot) return;
const loginDiv = simpleLogin.shadowRoot.querySelector('.login') as HTMLDivElement | null;
const loginContainerDiv = simpleLogin.shadowRoot.querySelector('.loginContainer') as HTMLDivElement | null;
const slotContainerDiv = simpleLogin.shadowRoot.querySelector('.slotContainer') as HTMLDivElement | null;
const form = simpleLogin.shadowRoot.querySelector('dees-form') as any;
if (loginDiv) {
loginDiv.style.opacity = '1';
loginDiv.style.transform = 'translateY(0px)';
}
if (loginContainerDiv) {
loginContainerDiv.style.pointerEvents = 'all';
}
if (slotContainerDiv) {
slotContainerDiv.style.opacity = '0';
slotContainerDiv.style.transform = 'translateY(20px)';
slotContainerDiv.style.pointerEvents = 'none';
}
if (form && statusMessageArg) {
form.setStatus('error', statusMessageArg);
}
}
private async logout() {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
await this.switchToLoginContent();
}
}
+1 -1
View File
@@ -472,7 +472,7 @@ export class CloudlyViewServices extends DeesElement {
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
try {
const response = await this.fireTypedRequest('getUpgradeableServices', {}) as { services: any[] };
const response = await this.fireTypedRequest('getUpgradeableAppStoreServices', {}) as { services: any[] };
this.upgradeInfo = response.services?.find((upgradeArg) => upgradeArg.serviceName === serviceArg.data.name) || null;
} catch {
this.upgradeInfo = null;
+5 -5
View File
@@ -4,6 +4,11 @@ export {
interfaces
}
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };
// @design.estate scope
import * as deesDomtools from '@design.estate/dees-domtools';
import * as deesElement from '@design.estate/dees-element';
@@ -11,11 +16,6 @@ import * as deesCatalog from '@design.estate/dees-catalog';
export { deesDomtools, deesElement, deesCatalog };
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };
// @push.rocks scope
import * as webjwt from '@push.rocks/webjwt';
import * as smartstate from '@push.rocks/smartstate';