2025-12-01 09:18:48 +00:00
|
|
|
import * as plugins from '../plugins.js';
|
|
|
|
|
import type { Reception } from './classes.reception.js';
|
|
|
|
|
import { App } from './classes.app.js';
|
2025-12-01 18:07:34 +00:00
|
|
|
// Note: App class is imported for use with setDefaultManagerForDoc
|
2025-12-01 09:18:48 +00:00
|
|
|
|
|
|
|
|
export class AppManager {
|
|
|
|
|
public receptionRef: Reception;
|
|
|
|
|
public get db() {
|
|
|
|
|
return this.receptionRef.db.smartdataDb;
|
|
|
|
|
}
|
|
|
|
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
|
|
|
|
|
|
|
|
public CApp = plugins.smartdata.setDefaultManagerForDoc(this, App);
|
|
|
|
|
|
|
|
|
|
constructor(receptionRefArg: Reception) {
|
|
|
|
|
this.receptionRef = receptionRefArg;
|
|
|
|
|
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
|
|
|
|
2025-12-01 09:44:37 +00:00
|
|
|
// Handler: Get all global apps (for org owners)
|
2025-12-01 09:18:48 +00:00
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
|
|
|
|
'getGlobalApps',
|
|
|
|
|
async (requestArg) => {
|
|
|
|
|
// Verify JWT
|
|
|
|
|
await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
|
|
|
|
|
|
|
|
|
// Get all active global apps
|
|
|
|
|
const globalApps = await this.CApp.getInstances({
|
|
|
|
|
type: 'global',
|
2025-12-01 09:44:37 +00:00
|
|
|
'data.isActive': true,
|
2025-12-01 09:18:48 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const appObjects = await Promise.all(
|
|
|
|
|
globalApps.map(async (app) => await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
apps: appObjects,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
2025-12-01 09:44:37 +00:00
|
|
|
|
|
|
|
|
// Handler: Check if user is global admin
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
|
|
|
|
|
'checkGlobalAdmin',
|
|
|
|
|
async (requestArg) => {
|
|
|
|
|
const user = await this.receptionRef.userManager.getUserByJwt(requestArg.jwt);
|
|
|
|
|
return {
|
|
|
|
|
isGlobalAdmin: user?.data?.isGlobalAdmin ?? false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Handler: Get global apps with stats (admin only)
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
|
|
|
|
'getGlobalAppStats',
|
|
|
|
|
async (requestArg) => {
|
2026-04-20 10:26:22 +00:00
|
|
|
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
|
|
|
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
|
|
|
|
id: jwtData.data.userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await this.receptionRef.alertManager.createAlertsForEvent({
|
|
|
|
|
category: 'admin',
|
|
|
|
|
eventType: 'global_admin_access',
|
|
|
|
|
severity: 'high',
|
|
|
|
|
title: 'Global admin console accessed',
|
|
|
|
|
body: `${user?.data?.email || 'A global admin'} accessed the global app administration dashboard.`,
|
|
|
|
|
actorUserId: jwtData.data.userId,
|
|
|
|
|
relatedEntityType: 'global-admin-console',
|
|
|
|
|
});
|
2025-12-01 09:44:37 +00:00
|
|
|
|
|
|
|
|
// Get all global apps (including inactive)
|
|
|
|
|
const globalApps = await this.CApp.getInstances({
|
|
|
|
|
type: 'global',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const appsWithStats = await Promise.all(
|
|
|
|
|
globalApps.map(async (app) => {
|
|
|
|
|
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
|
|
|
|
'data.appId': app.id,
|
|
|
|
|
'data.status': 'active',
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
|
|
|
|
|
connectionCount: connections.length,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { apps: appsWithStats };
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Handler: Create global app (admin only)
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
|
|
|
|
'createGlobalApp',
|
|
|
|
|
async (requestArg) => {
|
|
|
|
|
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
|
|
|
|
|
|
|
|
|
// Generate OAuth credentials
|
|
|
|
|
const clientId = `app-${plugins.smartunique.shortId(12)}`;
|
|
|
|
|
const clientSecret = plugins.smartunique.shortId(32);
|
|
|
|
|
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
|
|
|
|
|
|
2025-12-01 18:07:34 +00:00
|
|
|
const app = new this.CApp();
|
2025-12-01 09:44:37 +00:00
|
|
|
app.id = `app-${plugins.smartunique.shortId(8)}`;
|
|
|
|
|
app.type = 'global';
|
|
|
|
|
app.data = {
|
|
|
|
|
name: requestArg.name,
|
|
|
|
|
description: requestArg.description,
|
|
|
|
|
logoUrl: requestArg.logoUrl,
|
|
|
|
|
appUrl: requestArg.appUrl,
|
|
|
|
|
category: requestArg.category,
|
|
|
|
|
isActive: true,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
createdByUserId: jwtData.data.userId,
|
|
|
|
|
oauthCredentials: {
|
|
|
|
|
clientId,
|
|
|
|
|
clientSecretHash,
|
|
|
|
|
redirectUris: requestArg.redirectUris,
|
|
|
|
|
allowedScopes: requestArg.allowedScopes,
|
|
|
|
|
grantTypes: ['authorization_code', 'refresh_token'],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
await app.save();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
|
|
|
|
|
clientSecret, // Only shown once
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Handler: Update global app (admin only)
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
|
|
|
|
'updateGlobalApp',
|
|
|
|
|
async (requestArg) => {
|
|
|
|
|
await this.verifyGlobalAdmin(requestArg.jwt);
|
|
|
|
|
|
|
|
|
|
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
|
|
|
|
if (!app) {
|
|
|
|
|
throw new Error('App not found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!app.isGlobalApp()) {
|
|
|
|
|
throw new Error('Can only update global apps');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update allowed fields - cast data to global app type after type guard
|
|
|
|
|
const appData = app.data as plugins.idpInterfaces.data.IGlobalApp['data'];
|
|
|
|
|
if (requestArg.updates.name !== undefined) appData.name = requestArg.updates.name;
|
|
|
|
|
if (requestArg.updates.description !== undefined) appData.description = requestArg.updates.description;
|
|
|
|
|
if (requestArg.updates.logoUrl !== undefined) appData.logoUrl = requestArg.updates.logoUrl;
|
|
|
|
|
if (requestArg.updates.appUrl !== undefined) appData.appUrl = requestArg.updates.appUrl;
|
|
|
|
|
if (requestArg.updates.category !== undefined) appData.category = requestArg.updates.category;
|
|
|
|
|
if (requestArg.updates.isActive !== undefined) appData.isActive = requestArg.updates.isActive;
|
|
|
|
|
if (requestArg.updates.redirectUris !== undefined) appData.oauthCredentials.redirectUris = requestArg.updates.redirectUris;
|
|
|
|
|
if (requestArg.updates.allowedScopes !== undefined) appData.oauthCredentials.allowedScopes = requestArg.updates.allowedScopes;
|
|
|
|
|
|
|
|
|
|
await app.save();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Handler: Delete global app (admin only)
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
|
|
|
|
'deleteGlobalApp',
|
|
|
|
|
async (requestArg) => {
|
|
|
|
|
await this.verifyGlobalAdmin(requestArg.jwt);
|
|
|
|
|
|
|
|
|
|
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
|
|
|
|
if (!app) {
|
|
|
|
|
throw new Error('App not found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get and disconnect all connections
|
|
|
|
|
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
|
|
|
|
'data.appId': requestArg.appId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const connection of connections) {
|
|
|
|
|
await connection.delete();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await app.delete();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
disconnectedOrganizations: connections.length,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Handler: Regenerate OAuth credentials (admin only)
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
|
|
|
|
'regenerateAppCredentials',
|
|
|
|
|
async (requestArg) => {
|
2026-04-20 10:26:22 +00:00
|
|
|
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
2025-12-01 09:44:37 +00:00
|
|
|
|
|
|
|
|
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
|
|
|
|
if (!app) {
|
|
|
|
|
throw new Error('App not found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate new credentials
|
|
|
|
|
const clientId = `app-${plugins.smartunique.shortId(12)}`;
|
|
|
|
|
const clientSecret = plugins.smartunique.shortId(32);
|
|
|
|
|
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
|
|
|
|
|
|
|
|
|
|
app.data.oauthCredentials.clientId = clientId;
|
|
|
|
|
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
|
|
|
|
|
await app.save();
|
|
|
|
|
|
2026-04-20 10:26:22 +00:00
|
|
|
await this.receptionRef.alertManager.createAlertsForEvent({
|
|
|
|
|
category: 'security',
|
|
|
|
|
eventType: 'global_app_credentials_regenerated',
|
|
|
|
|
severity: 'critical',
|
|
|
|
|
title: 'Global app credentials regenerated',
|
|
|
|
|
body: `OAuth credentials for ${app.data.name} were regenerated.`,
|
|
|
|
|
actorUserId: jwtData.data.userId,
|
|
|
|
|
relatedEntityId: app.id,
|
|
|
|
|
relatedEntityType: 'global-app',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-01 09:44:37 +00:00
|
|
|
return {
|
|
|
|
|
clientId,
|
|
|
|
|
clientSecret, // Only shown once
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Verify that the user is a global admin
|
|
|
|
|
*/
|
|
|
|
|
private async verifyGlobalAdmin(jwt: string) {
|
|
|
|
|
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwt);
|
|
|
|
|
const user = await this.receptionRef.userManager.getUserByJwt(jwt);
|
|
|
|
|
if (!user?.data?.isGlobalAdmin) {
|
|
|
|
|
throw new Error('Access denied: Global admin privileges required');
|
|
|
|
|
}
|
|
|
|
|
return jwtData;
|
2025-12-01 09:18:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all global apps
|
|
|
|
|
*/
|
|
|
|
|
public async getGlobalApps(): Promise<App[]> {
|
|
|
|
|
return await this.CApp.getInstances({
|
|
|
|
|
type: 'global',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get app by ID
|
|
|
|
|
*/
|
|
|
|
|
public async getAppById(appId: string): Promise<App | null> {
|
|
|
|
|
return await this.CApp.getInstance({
|
|
|
|
|
id: appId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Seed initial global apps (for development/testing)
|
|
|
|
|
*/
|
|
|
|
|
public async seedGlobalApps() {
|
|
|
|
|
const defaultGlobalApps: Partial<plugins.idpInterfaces.data.IGlobalApp>[] = [
|
|
|
|
|
{
|
|
|
|
|
id: 'app-foss-global',
|
|
|
|
|
type: 'global',
|
|
|
|
|
data: {
|
|
|
|
|
name: 'foss.global',
|
|
|
|
|
description: 'Open Source Package Registry and Collaboration Platform',
|
|
|
|
|
logoUrl: 'https://foss.global/assets/logo.png',
|
|
|
|
|
appUrl: 'https://foss.global',
|
|
|
|
|
oauthCredentials: {
|
|
|
|
|
clientId: 'foss-global-client',
|
|
|
|
|
clientSecretHash: '', // Will be set when OAuth is configured
|
|
|
|
|
redirectUris: ['https://foss.global/auth/callback'],
|
|
|
|
|
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
|
|
|
|
|
grantTypes: ['authorization_code', 'refresh_token'],
|
|
|
|
|
},
|
|
|
|
|
isActive: true,
|
|
|
|
|
category: 'Development',
|
2025-12-01 09:44:37 +00:00
|
|
|
createdAt: Date.now(),
|
|
|
|
|
createdByUserId: 'system',
|
2025-12-01 09:18:48 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'app-task-vc',
|
|
|
|
|
type: 'global',
|
|
|
|
|
data: {
|
|
|
|
|
name: 'task.vc',
|
|
|
|
|
description: 'Task Management and Project Collaboration',
|
|
|
|
|
logoUrl: 'https://task.vc/assets/logo.png',
|
|
|
|
|
appUrl: 'https://task.vc',
|
|
|
|
|
oauthCredentials: {
|
|
|
|
|
clientId: 'task-vc-client',
|
|
|
|
|
clientSecretHash: '',
|
|
|
|
|
redirectUris: ['https://task.vc/auth/callback'],
|
|
|
|
|
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
|
|
|
|
|
grantTypes: ['authorization_code', 'refresh_token'],
|
|
|
|
|
},
|
|
|
|
|
isActive: true,
|
|
|
|
|
category: 'Productivity',
|
2025-12-01 09:44:37 +00:00
|
|
|
createdAt: Date.now(),
|
|
|
|
|
createdByUserId: 'system',
|
2025-12-01 09:18:48 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const appData of defaultGlobalApps) {
|
|
|
|
|
const existing = await this.CApp.getInstance({ id: appData.id });
|
|
|
|
|
if (!existing) {
|
2025-12-01 18:07:34 +00:00
|
|
|
const app = new this.CApp();
|
2025-12-01 09:18:48 +00:00
|
|
|
app.id = appData.id!;
|
|
|
|
|
app.type = appData.type!;
|
|
|
|
|
app.data = appData.data as any;
|
|
|
|
|
await app.save();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|