feat(apps): Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation

This commit is contained in:
2025-12-01 09:18:48 +00:00
parent f54588e877
commit 6b04c529da
28 changed files with 1491 additions and 21 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.5.0',
version: '1.6.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
+40
View File
@@ -0,0 +1,40 @@
import * as plugins from '../plugins.js';
import type { AppManager } from './classes.appmanager.js';
@plugins.smartdata.Manager()
export class App extends plugins.smartdata.SmartDataDbDoc<
App,
plugins.idpInterfaces.data.IAppDocument,
AppManager
> {
// INSTANCE
@plugins.smartdata.unI()
id: plugins.idpInterfaces.data.IAppDocument['id'];
@plugins.smartdata.svDb()
type: plugins.idpInterfaces.data.IAppDocument['type'];
@plugins.smartdata.svDb()
data: plugins.idpInterfaces.data.IAppDocument['data'];
/**
* Check if the app is a global app
*/
public isGlobalApp(): this is App & { type: 'global' } {
return this.type === 'global';
}
/**
* Check if the app is a partner app
*/
public isPartnerApp(): this is App & { type: 'partner' } {
return this.type === 'partner';
}
/**
* Check if the app is a custom OIDC app
*/
public isCustomOidcApp(): this is App & { type: 'custom_oidc' } {
return this.type === 'custom_oidc';
}
}
+41
View File
@@ -0,0 +1,41 @@
import * as plugins from '../plugins.js';
import type { AppConnectionManager } from './classes.appconnectionmanager.js';
@plugins.smartdata.Manager()
export class AppConnection extends plugins.smartdata.SmartDataDbDoc<
AppConnection,
plugins.idpInterfaces.data.IAppConnection,
AppConnectionManager
> {
// INSTANCE
@plugins.smartdata.unI()
id: plugins.idpInterfaces.data.IAppConnection['id'];
@plugins.smartdata.svDb()
data: plugins.idpInterfaces.data.IAppConnection['data'];
/**
* Check if the connection is active
*/
public isActive(): boolean {
return this.data.status === 'active';
}
/**
* Disconnect the app
*/
public async disconnect(): Promise<void> {
this.data.status = 'disconnected';
await this.save();
}
/**
* Reconnect the app
*/
public async reconnect(userId: string): Promise<void> {
this.data.status = 'active';
this.data.connectedAt = Date.now();
this.data.connectedByUserId = userId;
await this.save();
}
}
@@ -0,0 +1,187 @@
import * as plugins from '../plugins.js';
import type { Reception } from './classes.reception.js';
import { AppConnection } from './classes.appconnection.js';
export class AppConnectionManager {
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedrouter = new plugins.typedrequest.TypedRouter();
public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
// Handler: Get app connections for an organization
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'getAppConnections',
async (requestArg) => {
// Verify JWT and get user
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
const user = await this.receptionRef.userManager.CUser.getInstance({
id: jwtData.data.userId,
});
// Check user has access to the organization
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: requestArg.organizationId,
});
if (!organization) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: {
organizationId: organization.id,
userId: user.id,
},
});
if (!role) {
throw new plugins.typedrequest.TypedResponseError(
'User not authorized for this organization'
);
}
// Get all connections for this organization
const connections = await this.CAppConnection.getInstances({
'data.organizationId': requestArg.organizationId,
});
const connectionObjects = await Promise.all(
connections.map(async (conn) => await conn.createSavableObject())
);
return {
connections: connectionObjects,
};
}
)
);
// Handler: Toggle app connection (connect/disconnect)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
'toggleAppConnection',
async (requestArg) => {
// Verify JWT and get user
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
const user = await this.receptionRef.userManager.CUser.getInstance({
id: jwtData.data.userId,
});
// Check user has admin access to the organization
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: requestArg.organizationId,
});
if (!organization) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
const isAdmin = await organization.checkIfUserIsAdmin(user);
if (!isAdmin) {
throw new plugins.typedrequest.TypedResponseError(
'Only organization admins can manage app connections'
);
}
// Get the app
const app = await this.receptionRef.appManager.getAppById(requestArg.appId);
if (!app) {
throw new plugins.typedrequest.TypedResponseError('App not found');
}
// Find existing connection
let connection = await this.CAppConnection.getInstance({
'data.organizationId': requestArg.organizationId,
'data.appId': requestArg.appId,
});
if (requestArg.action === 'connect') {
if (connection && connection.isActive()) {
// Already connected
return {
success: true,
connection: await connection.createSavableObject(),
};
}
if (connection) {
// Reconnect existing connection
await connection.reconnect(user.id);
} else {
// Create new connection
connection = new AppConnection();
connection.id = plugins.smartunique.shortId();
connection.data = {
organizationId: requestArg.organizationId,
appId: requestArg.appId,
appType: app.type,
status: 'active',
connectedAt: Date.now(),
connectedByUserId: user.id,
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
};
await connection.save();
}
return {
success: true,
connection: await connection.createSavableObject(),
};
} else {
// Disconnect
if (!connection) {
return {
success: true,
};
}
await connection.disconnect();
return {
success: true,
connection: await connection.createSavableObject(),
};
}
}
)
);
}
/**
* Get all connections for an organization
*/
public async getConnectionsForOrganization(organizationId: string): Promise<AppConnection[]> {
return await this.CAppConnection.getInstances({
'data.organizationId': organizationId,
});
}
/**
* Get connection for a specific app and organization
*/
public async getConnection(
organizationId: string,
appId: string
): Promise<AppConnection | null> {
return await this.CAppConnection.getInstance({
'data.organizationId': organizationId,
'data.appId': appId,
});
}
/**
* Check if an app is connected to an organization
*/
public async isAppConnected(organizationId: string, appId: string): Promise<boolean> {
const connection = await this.getConnection(organizationId, appId);
return connection?.isActive() || false;
}
}
+117
View File
@@ -0,0 +1,117 @@
import * as plugins from '../plugins.js';
import type { Reception } from './classes.reception.js';
import { App } from './classes.app.js';
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);
// Handler: Get all global apps
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',
});
const appObjects = await Promise.all(
globalApps.map(async (app) => await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp)
);
return {
apps: appObjects,
};
}
)
);
}
/**
* 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',
},
},
{
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',
},
},
];
for (const appData of defaultGlobalApps) {
const existing = await this.CApp.getInstance({ id: appData.id });
if (!existing) {
const app = new App();
app.id = appData.id!;
app.type = appData.type!;
app.data = appData.data as any;
await app.save();
}
}
}
}
+4
View File
@@ -13,6 +13,8 @@ import { ReceptionHousekeeping } from './classes.housekeeping.js';
import { OrganizationManager } from './classes.organizationmanager.js';
import { RoleManager } from './classes.rolemanager.js';
import { BillingPlanManager } from './classes.billingplanmanager.js';
import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js';
export interface IReceptionOptions {
/**
@@ -41,6 +43,8 @@ export class Reception {
public organizationmanager = new OrganizationManager(this);
public roleManager = new RoleManager(this);
public billingPlanManager = new BillingPlanManager(this);
public appManager = new AppManager(this);
public appConnectionManager = new AppConnectionManager(this);
housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) {