feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend
This commit is contained in:
51
ts/opsserver/classes.opsserver.ts
Normal file
51
ts/opsserver/classes.opsserver.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { StackGalleryRegistry } from '../registry.ts';
|
||||
import * as handlers from './handlers/index.ts';
|
||||
|
||||
export class OpsServer {
|
||||
public registryRef: StackGalleryRegistry;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Handler instances
|
||||
public authHandler!: handlers.AuthHandler;
|
||||
public organizationHandler!: handlers.OrganizationHandler;
|
||||
public repositoryHandler!: handlers.RepositoryHandler;
|
||||
public packageHandler!: handlers.PackageHandler;
|
||||
public tokenHandler!: handlers.TokenHandler;
|
||||
public auditHandler!: handlers.AuditHandler;
|
||||
public adminHandler!: handlers.AdminHandler;
|
||||
public oauthHandler!: handlers.OAuthHandler;
|
||||
public userHandler!: handlers.UserHandler;
|
||||
|
||||
constructor(registryRef: StackGalleryRegistry) {
|
||||
this.registryRef = registryRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all handlers. Must be called before routing requests.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// AuthHandler must be initialized first (other handlers depend on its guards)
|
||||
this.authHandler = new handlers.AuthHandler(this);
|
||||
await this.authHandler.initialize();
|
||||
|
||||
// All other handlers self-register in their constructors
|
||||
this.organizationHandler = new handlers.OrganizationHandler(this);
|
||||
this.repositoryHandler = new handlers.RepositoryHandler(this);
|
||||
this.packageHandler = new handlers.PackageHandler(this);
|
||||
this.tokenHandler = new handlers.TokenHandler(this);
|
||||
this.auditHandler = new handlers.AuditHandler(this);
|
||||
this.adminHandler = new handlers.AdminHandler(this);
|
||||
this.oauthHandler = new handlers.OAuthHandler(this);
|
||||
this.userHandler = new handlers.UserHandler(this);
|
||||
|
||||
console.log('[OpsServer] TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
console.log('[OpsServer] Stopped');
|
||||
}
|
||||
}
|
||||
380
ts/opsserver/handlers/admin.handler.ts
Normal file
380
ts/opsserver/handlers/admin.handler.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
|
||||
import { cryptoService } from '../../services/crypto.service.ts';
|
||||
import { externalAuthService } from '../../services/external.auth.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
|
||||
export class AdminHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Admin Providers
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminProviders>(
|
||||
'getAdminProviders',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const providers = await AuthProvider.getAllProviders();
|
||||
return {
|
||||
providers: providers.map((p) => p.toAdminInfo()),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list providers');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateAdminProvider>(
|
||||
'createAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { name, displayName, type, oauthConfig, ldapConfig, attributeMapping, provisioning } = dataArg;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !displayName || !type) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'name, displayName, and type are required',
|
||||
);
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
const existing = await AuthProvider.findByName(name);
|
||||
if (existing) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Provider name already exists');
|
||||
}
|
||||
|
||||
// Validate type-specific config
|
||||
if (type === 'oidc' && !oauthConfig) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'oauthConfig is required for OIDC provider',
|
||||
);
|
||||
}
|
||||
if (type === 'ldap' && !ldapConfig) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'ldapConfig is required for LDAP provider',
|
||||
);
|
||||
}
|
||||
|
||||
let provider: AuthProvider;
|
||||
|
||||
if (type === 'oidc' && oauthConfig) {
|
||||
// Encrypt client secret
|
||||
const encryptedSecret = await cryptoService.encrypt(
|
||||
oauthConfig.clientSecretEncrypted,
|
||||
);
|
||||
|
||||
provider = await AuthProvider.createOAuthProvider({
|
||||
name,
|
||||
displayName,
|
||||
oauthConfig: {
|
||||
...oauthConfig,
|
||||
clientSecretEncrypted: encryptedSecret,
|
||||
},
|
||||
attributeMapping,
|
||||
provisioning,
|
||||
createdById: dataArg.identity.userId,
|
||||
});
|
||||
} else if (type === 'ldap' && ldapConfig) {
|
||||
// Encrypt bind password
|
||||
const encryptedPassword = await cryptoService.encrypt(
|
||||
ldapConfig.bindPasswordEncrypted,
|
||||
);
|
||||
|
||||
provider = await AuthProvider.createLdapProvider({
|
||||
name,
|
||||
displayName,
|
||||
ldapConfig: {
|
||||
...ldapConfig,
|
||||
bindPasswordEncrypted: encryptedPassword,
|
||||
},
|
||||
attributeMapping,
|
||||
provisioning,
|
||||
createdById: dataArg.identity.userId,
|
||||
});
|
||||
} else {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid provider type');
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('AUTH_PROVIDER_CREATED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
},
|
||||
});
|
||||
|
||||
return { provider: provider.toAdminInfo() };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to create provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminProvider>(
|
||||
'getAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||
}
|
||||
|
||||
return { provider: provider.toAdminInfo() };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAdminProvider>(
|
||||
'updateAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||
}
|
||||
|
||||
// Update basic fields
|
||||
if (dataArg.displayName !== undefined) provider.displayName = dataArg.displayName;
|
||||
if (dataArg.status !== undefined) provider.status = dataArg.status as any;
|
||||
if (dataArg.priority !== undefined) provider.priority = dataArg.priority;
|
||||
|
||||
// Update OAuth config
|
||||
if (dataArg.oauthConfig && provider.oauthConfig) {
|
||||
const newOAuthConfig = { ...provider.oauthConfig, ...dataArg.oauthConfig };
|
||||
|
||||
// Encrypt new client secret if provided and not already encrypted
|
||||
if (
|
||||
dataArg.oauthConfig.clientSecretEncrypted &&
|
||||
!cryptoService.isEncrypted(dataArg.oauthConfig.clientSecretEncrypted)
|
||||
) {
|
||||
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
||||
dataArg.oauthConfig.clientSecretEncrypted,
|
||||
);
|
||||
}
|
||||
|
||||
provider.oauthConfig = newOAuthConfig;
|
||||
}
|
||||
|
||||
// Update LDAP config
|
||||
if (dataArg.ldapConfig && provider.ldapConfig) {
|
||||
const newLdapConfig = { ...provider.ldapConfig, ...dataArg.ldapConfig };
|
||||
|
||||
// Encrypt new bind password if provided and not already encrypted
|
||||
if (
|
||||
dataArg.ldapConfig.bindPasswordEncrypted &&
|
||||
!cryptoService.isEncrypted(dataArg.ldapConfig.bindPasswordEncrypted)
|
||||
) {
|
||||
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
||||
dataArg.ldapConfig.bindPasswordEncrypted,
|
||||
);
|
||||
}
|
||||
|
||||
provider.ldapConfig = newLdapConfig;
|
||||
}
|
||||
|
||||
// Update attribute mapping
|
||||
if (dataArg.attributeMapping) {
|
||||
provider.attributeMapping = {
|
||||
...provider.attributeMapping,
|
||||
...dataArg.attributeMapping,
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Update provisioning settings
|
||||
if (dataArg.provisioning) {
|
||||
provider.provisioning = {
|
||||
...provider.provisioning,
|
||||
...dataArg.provisioning,
|
||||
} as any;
|
||||
}
|
||||
|
||||
await provider.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('AUTH_PROVIDER_UPDATED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: { providerName: provider.name },
|
||||
});
|
||||
|
||||
return { provider: provider.toAdminInfo() };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAdminProvider>(
|
||||
'deleteAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||
}
|
||||
|
||||
// Soft delete - disable instead of removing
|
||||
provider.status = 'disabled';
|
||||
await provider.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: { providerName: provider.name },
|
||||
});
|
||||
|
||||
return { message: 'Provider disabled' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Test Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestAdminProvider>(
|
||||
'testAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const result = await externalAuthService.testConnection(dataArg.providerId);
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
|
||||
resourceId: dataArg.providerId,
|
||||
success: result.success,
|
||||
metadata: {
|
||||
result: result.success ? 'success' : 'failure',
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
},
|
||||
});
|
||||
|
||||
return { result };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to test provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Platform Settings
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformSettings>(
|
||||
'getPlatformSettings',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
return {
|
||||
settings: {
|
||||
id: settings.id,
|
||||
auth: settings.auth,
|
||||
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
|
||||
updatedById: settings.updatedById,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get settings');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Platform Settings
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdatePlatformSettings>(
|
||||
'updatePlatformSettings',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
|
||||
if (dataArg.auth) {
|
||||
await settings.updateAuthSettings(dataArg.auth as any, dataArg.identity.userId);
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
|
||||
resourceId: 'platform-settings',
|
||||
success: true,
|
||||
});
|
||||
|
||||
return {
|
||||
settings: {
|
||||
id: settings.id,
|
||||
auth: settings.auth,
|
||||
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
|
||||
updatedById: settings.updatedById,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update settings');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
ts/opsserver/handlers/audit.handler.ts
Normal file
105
ts/opsserver/handlers/audit.handler.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { AuditLog } from '../../models/auditlog.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
|
||||
export class AuditHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Query Audit Logs
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_QueryAudit>(
|
||||
'queryAudit',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const {
|
||||
organizationId,
|
||||
repositoryId,
|
||||
resourceType,
|
||||
actions,
|
||||
success,
|
||||
startDate: startDateStr,
|
||||
endDate: endDateStr,
|
||||
limit: limitParam,
|
||||
offset: offsetParam,
|
||||
} = dataArg;
|
||||
|
||||
const limit = limitParam || 100;
|
||||
const offset = offsetParam || 0;
|
||||
const startDate = startDateStr ? new Date(startDateStr) : undefined;
|
||||
const endDate = endDateStr ? new Date(endDateStr) : undefined;
|
||||
|
||||
// Determine actor filter based on permissions
|
||||
let actorId: string | undefined;
|
||||
|
||||
if (dataArg.identity.isSystemAdmin) {
|
||||
// System admins can see all
|
||||
actorId = dataArg.actorId;
|
||||
} else if (organizationId) {
|
||||
// Check if user can manage this org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
// User can only see their own actions in this org
|
||||
actorId = dataArg.identity.userId;
|
||||
}
|
||||
} else {
|
||||
// Non-admins without org filter can only see their own actions
|
||||
actorId = dataArg.identity.userId;
|
||||
}
|
||||
|
||||
const result = await AuditLog.query({
|
||||
actorId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
resourceType: resourceType as any,
|
||||
action: actions as any[],
|
||||
success,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
logs: result.logs.map((log) => ({
|
||||
id: log.id,
|
||||
actorId: log.actorId,
|
||||
actorType: log.actorType as interfaces.data.IAuditEntry['actorType'],
|
||||
action: log.action as interfaces.data.TAuditAction,
|
||||
resourceType: log.resourceType as interfaces.data.TAuditResourceType,
|
||||
resourceId: log.resourceId,
|
||||
resourceName: log.resourceName,
|
||||
organizationId: log.organizationId,
|
||||
repositoryId: log.repositoryId,
|
||||
success: log.success,
|
||||
errorCode: log.errorCode,
|
||||
timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : String(log.timestamp),
|
||||
metadata: log.metadata || {},
|
||||
})),
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to query audit logs');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
ts/opsserver/handlers/auth.handler.ts
Normal file
263
ts/opsserver/handlers/auth.handler.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
import { User } from '../../models/user.ts';
|
||||
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
|
||||
|
||||
export class AuthHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private authService: AuthService;
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth handler - must be called after construction
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Login
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_Login>(
|
||||
'login',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const { email, password } = dataArg;
|
||||
|
||||
if (!email || !password) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Email and password are required');
|
||||
}
|
||||
|
||||
const result = await this.authService.login(email, password);
|
||||
|
||||
if (!result.success || !result.user || !result.accessToken || !result.refreshToken) {
|
||||
return {
|
||||
errorCode: result.errorCode,
|
||||
errorMessage: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const user = result.user;
|
||||
const expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
const identity: interfaces.data.IIdentity = {
|
||||
jwt: result.accessToken,
|
||||
refreshJwt: result.refreshToken,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
expiresAt,
|
||||
sessionId: result.sessionId!,
|
||||
};
|
||||
|
||||
return {
|
||||
identity,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt instanceof Date
|
||||
? user.createdAt.toISOString()
|
||||
: String(user.createdAt),
|
||||
lastLoginAt: user.lastLoginAt instanceof Date
|
||||
? user.lastLoginAt.toISOString()
|
||||
: user.lastLoginAt
|
||||
? String(user.lastLoginAt)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Login failed');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Refresh Token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshToken>(
|
||||
'refreshToken',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
if (!dataArg.identity?.refreshJwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Refresh token is required');
|
||||
}
|
||||
|
||||
const result = await this.authService.refresh(dataArg.identity.refreshJwt);
|
||||
|
||||
if (!result.success || !result.user || !result.accessToken) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
result.errorMessage || 'Token refresh failed',
|
||||
);
|
||||
}
|
||||
|
||||
const user = result.user;
|
||||
const expiresAt = Date.now() + 15 * 60 * 1000;
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt: result.accessToken,
|
||||
refreshJwt: dataArg.identity.refreshJwt,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
expiresAt,
|
||||
sessionId: result.sessionId || dataArg.identity.sessionId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Token refresh failed');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Logout
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_Logout>(
|
||||
'logout',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Identity required');
|
||||
}
|
||||
|
||||
if (dataArg.all) {
|
||||
const count = await this.authService.logoutAll(dataArg.identity.userId);
|
||||
return { message: `Logged out from ${count} sessions` };
|
||||
}
|
||||
|
||||
const sessionId = dataArg.sessionId || dataArg.identity.sessionId;
|
||||
if (sessionId) {
|
||||
await this.authService.logout(sessionId, {
|
||||
userId: dataArg.identity.userId,
|
||||
});
|
||||
}
|
||||
|
||||
return { message: 'Logged out successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Logout failed');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Me
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetMe>(
|
||||
'getMe',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Identity required');
|
||||
}
|
||||
|
||||
const validated = await this.authService.validateAccessToken(dataArg.identity.jwt);
|
||||
if (!validated) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid or expired token');
|
||||
}
|
||||
|
||||
const user = validated.user;
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt instanceof Date
|
||||
? user.createdAt.toISOString()
|
||||
: String(user.createdAt),
|
||||
lastLoginAt: user.lastLoginAt instanceof Date
|
||||
? user.lastLoginAt.toISOString()
|
||||
: user.lastLoginAt
|
||||
? String(user.lastLoginAt)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get user info');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Auth Providers (public)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAuthProviders>(
|
||||
'getAuthProviders',
|
||||
async (_dataArg) => {
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
const providers = await AuthProvider.getActiveProviders();
|
||||
|
||||
return {
|
||||
providers: providers.map((p) => p.toPublicInfo()),
|
||||
localAuthEnabled: settings.auth.localAuthEnabled,
|
||||
defaultProviderId: settings.auth.defaultProviderId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get auth providers');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Guard for valid identity - validates JWT via AuthService
|
||||
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) return false;
|
||||
try {
|
||||
const validated = await this.authService.validateAccessToken(dataArg.identity.jwt);
|
||||
if (!validated) return false;
|
||||
// Verify the userId matches the identity claim
|
||||
if (dataArg.identity.userId !== validated.user.id) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ failedHint: 'identity is not valid', name: 'validIdentityGuard' },
|
||||
);
|
||||
|
||||
// Guard for admin identity - validates JWT + checks isSystemAdmin
|
||||
public adminIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!isValid) return false;
|
||||
// Check isSystemAdmin claim from the identity
|
||||
if (!dataArg.identity.isSystemAdmin) return false;
|
||||
// Double-check from database
|
||||
const user = await User.findById(dataArg.identity.userId);
|
||||
if (!user || !user.isSystemAdmin) return false;
|
||||
return true;
|
||||
},
|
||||
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
|
||||
);
|
||||
}
|
||||
9
ts/opsserver/handlers/index.ts
Normal file
9
ts/opsserver/handlers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './auth.handler.ts';
|
||||
export * from './organization.handler.ts';
|
||||
export * from './repository.handler.ts';
|
||||
export * from './package.handler.ts';
|
||||
export * from './token.handler.ts';
|
||||
export * from './audit.handler.ts';
|
||||
export * from './admin.handler.ts';
|
||||
export * from './oauth.handler.ts';
|
||||
export * from './user.handler.ts';
|
||||
160
ts/opsserver/handlers/oauth.handler.ts
Normal file
160
ts/opsserver/handlers/oauth.handler.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { externalAuthService } from '../../services/external.auth.service.ts';
|
||||
|
||||
export class OAuthHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// OAuth Authorize - initiate OAuth flow
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_OAuthAuthorize>(
|
||||
'oauthAuthorize',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const { providerId, returnUrl } = dataArg;
|
||||
|
||||
const { authUrl } = await externalAuthService.initiateOAuth(providerId, returnUrl);
|
||||
|
||||
return { redirectUrl: authUrl };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Authorization failed';
|
||||
throw new plugins.typedrequest.TypedResponseError(errorMessage);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// OAuth Callback - handle provider callback
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_OAuthCallback>(
|
||||
'oauthCallback',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const { code, state } = dataArg;
|
||||
|
||||
if (!code || !state) {
|
||||
return {
|
||||
errorCode: 'MISSING_PARAMETERS',
|
||||
errorMessage: 'Code and state are required',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await externalAuthService.handleOAuthCallback(
|
||||
{ code, state },
|
||||
{},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
errorCode: result.errorCode,
|
||||
errorMessage: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const user = result.user!;
|
||||
const expiresAt = Date.now() + 15 * 60 * 1000;
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt: result.accessToken!,
|
||||
refreshJwt: result.refreshToken!,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
expiresAt,
|
||||
sessionId: result.sessionId!,
|
||||
},
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
|
||||
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('OAuth callback failed');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// LDAP Login
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_LdapLogin>(
|
||||
'ldapLogin',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const { providerId, username, password } = dataArg;
|
||||
|
||||
if (!username || !password) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Username and password are required',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await externalAuthService.authenticateLdap(
|
||||
providerId,
|
||||
username,
|
||||
password,
|
||||
{},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
errorCode: result.errorCode,
|
||||
errorMessage: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const user = result.user!;
|
||||
const expiresAt = Date.now() + 15 * 60 * 1000;
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt: result.accessToken!,
|
||||
refreshJwt: result.refreshToken!,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
expiresAt,
|
||||
sessionId: result.sessionId!,
|
||||
},
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
|
||||
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('LDAP login failed');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
548
ts/opsserver/handlers/organization.handler.ts
Normal file
548
ts/opsserver/handlers/organization.handler.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { Organization, OrganizationMember, User } from '../../models/index.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
|
||||
export class OrganizationHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve organization by ID or name
|
||||
*/
|
||||
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
|
||||
return idOrName.startsWith('Organization:')
|
||||
? await Organization.findById(idOrName)
|
||||
: await Organization.findByName(idOrName);
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Organizations
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizations>(
|
||||
'getOrganizations',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const userId = dataArg.identity.userId;
|
||||
let organizations: Organization[];
|
||||
|
||||
if (dataArg.identity.isSystemAdmin) {
|
||||
organizations = await Organization.getInstances({});
|
||||
} else {
|
||||
const memberships = await OrganizationMember.getUserOrganizations(userId);
|
||||
const orgs: Organization[] = [];
|
||||
for (const m of memberships) {
|
||||
const org = await Organization.findById(m.organizationId);
|
||||
if (org) orgs.push(org);
|
||||
}
|
||||
organizations = orgs;
|
||||
}
|
||||
|
||||
return {
|
||||
organizations: organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
plan: (org as any).plan || 'free',
|
||||
usedStorageBytes: org.usedStorageBytes || 0,
|
||||
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||
createdAt: org.createdAt instanceof Date
|
||||
? org.createdAt.toISOString()
|
||||
: String(org.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list organizations');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganization>(
|
||||
'getOrganization',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check access - public orgs visible to all, private requires membership
|
||||
if (!org.isPublic) {
|
||||
const isMember = await OrganizationMember.findMembership(
|
||||
org.id,
|
||||
dataArg.identity.userId,
|
||||
);
|
||||
if (!isMember && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const orgData: interfaces.data.IOrganizationDetail = {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
plan: (org as any).plan || 'free',
|
||||
usedStorageBytes: org.usedStorageBytes || 0,
|
||||
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||
createdAt: org.createdAt instanceof Date
|
||||
? org.createdAt.toISOString()
|
||||
: String(org.createdAt),
|
||||
};
|
||||
|
||||
// Include settings for admins
|
||||
if (dataArg.identity.isSystemAdmin && org.settings) {
|
||||
orgData.settings = org.settings as any;
|
||||
}
|
||||
|
||||
return { organization: orgData };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get organization');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create Organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateOrganization>(
|
||||
'createOrganization',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { name, displayName, description, isPublic } = dataArg;
|
||||
|
||||
if (!name) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization name is required');
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Name must be lowercase alphanumeric with optional hyphens and dots',
|
||||
);
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
const existing = await Organization.findByName(name);
|
||||
if (existing) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization name already taken');
|
||||
}
|
||||
|
||||
// Create organization
|
||||
const org = new Organization();
|
||||
org.id = await Organization.getNewId();
|
||||
org.name = name;
|
||||
org.displayName = displayName || name;
|
||||
org.description = description;
|
||||
org.isPublic = isPublic ?? false;
|
||||
org.memberCount = 1;
|
||||
org.createdAt = new Date();
|
||||
org.createdById = dataArg.identity.userId;
|
||||
|
||||
await org.save();
|
||||
|
||||
// Add creator as owner
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = dataArg.identity.userId;
|
||||
membership.role = 'owner';
|
||||
membership.invitedBy = dataArg.identity.userId;
|
||||
membership.joinedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).logOrganizationCreated(org.id, org.name);
|
||||
|
||||
return {
|
||||
organization: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
plan: (org as any).plan || 'free',
|
||||
usedStorageBytes: org.usedStorageBytes || 0,
|
||||
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||
createdAt: org.createdAt instanceof Date
|
||||
? org.createdAt.toISOString()
|
||||
: String(org.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to create organization');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganization>(
|
||||
'updateOrganization',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
if (dataArg.displayName !== undefined) org.displayName = dataArg.displayName;
|
||||
if (dataArg.description !== undefined) org.description = dataArg.description;
|
||||
if (dataArg.avatarUrl !== undefined) org.avatarUrl = dataArg.avatarUrl;
|
||||
if (dataArg.website !== undefined) org.website = dataArg.website;
|
||||
if (dataArg.isPublic !== undefined) org.isPublic = dataArg.isPublic;
|
||||
|
||||
// Only system admins can change settings
|
||||
if (dataArg.settings && dataArg.identity.isSystemAdmin) {
|
||||
org.settings = { ...org.settings, ...dataArg.settings } as any;
|
||||
}
|
||||
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
organization: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
plan: (org as any).plan || 'free',
|
||||
usedStorageBytes: org.usedStorageBytes || 0,
|
||||
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||
createdAt: org.createdAt instanceof Date
|
||||
? org.createdAt.toISOString()
|
||||
: String(org.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update organization');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrganization>(
|
||||
'deleteOrganization',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(
|
||||
org.id,
|
||||
dataArg.identity.userId,
|
||||
);
|
||||
if (membership?.role !== 'owner' && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Owner access required');
|
||||
}
|
||||
|
||||
await org.delete();
|
||||
|
||||
return { message: 'Organization deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete organization');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Organization Members
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizationMembers>(
|
||||
'getOrganizationMembers',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(
|
||||
org.id,
|
||||
dataArg.identity.userId,
|
||||
);
|
||||
if (!isMember && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
|
||||
const membersWithUsers = await Promise.all(
|
||||
members.map(async (m) => {
|
||||
const user = await User.findById(m.userId);
|
||||
return {
|
||||
userId: m.userId,
|
||||
role: m.role as interfaces.data.TOrganizationRole,
|
||||
addedAt: m.joinedAt instanceof Date
|
||||
? m.joinedAt.toISOString()
|
||||
: String(m.joinedAt),
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { members: membersWithUsers };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list members');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Add Organization Member
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AddOrganizationMember>(
|
||||
'addOrganizationMember',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
const { userId, role } = dataArg;
|
||||
|
||||
if (!userId || !role) {
|
||||
throw new plugins.typedrequest.TypedResponseError('userId and role are required');
|
||||
}
|
||||
|
||||
if (!['owner', 'admin', 'member'].includes(role)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid role');
|
||||
}
|
||||
|
||||
// Check user exists
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existing = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (existing) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User is already a member');
|
||||
}
|
||||
|
||||
// Add member
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.invitedBy = dataArg.identity.userId;
|
||||
membership.joinedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Update member count
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
member: {
|
||||
userId: membership.userId,
|
||||
role: membership.role as interfaces.data.TOrganizationRole,
|
||||
addedAt: membership.joinedAt instanceof Date
|
||||
? membership.joinedAt.toISOString()
|
||||
: String(membership.joinedAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to add member');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Organization Member
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganizationMember>(
|
||||
'updateOrganizationMember',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
const { userId, role } = dataArg;
|
||||
|
||||
if (!role || !['owner', 'admin', 'member'].includes(role)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid role is required');
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (!membership) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Member not found');
|
||||
}
|
||||
|
||||
// Cannot change last owner
|
||||
if (membership.role === 'owner' && role !== 'owner') {
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
|
||||
}
|
||||
}
|
||||
|
||||
membership.role = role;
|
||||
await membership.save();
|
||||
|
||||
return {
|
||||
member: {
|
||||
userId: membership.userId,
|
||||
role: membership.role as interfaces.data.TOrganizationRole,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update member');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Remove Organization Member
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveOrganizationMember>(
|
||||
'removeOrganizationMember',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (dataArg.userId !== dataArg.identity.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(org.id, dataArg.userId);
|
||||
if (!membership) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Member not found');
|
||||
}
|
||||
|
||||
// Cannot remove last owner
|
||||
if (membership.role === 'owner') {
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
|
||||
}
|
||||
}
|
||||
|
||||
await membership.delete();
|
||||
|
||||
// Update member count
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
|
||||
return { message: 'Member removed successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to remove member');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
315
ts/opsserver/handlers/package.handler.ts
Normal file
315
ts/opsserver/handlers/package.handler.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { Package, Repository } from '../../models/index.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
|
||||
export class PackageHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Search Packages
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SearchPackages>(
|
||||
'searchPackages',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const query = dataArg.query || '';
|
||||
const protocol = dataArg.protocol;
|
||||
const organizationId = dataArg.organizationId;
|
||||
const limit = dataArg.limit || 50;
|
||||
const offset = dataArg.offset || 0;
|
||||
|
||||
// Determine visibility: anonymous users see only public packages
|
||||
const hasIdentity = !!dataArg.identity?.jwt;
|
||||
const isPrivate = hasIdentity ? undefined : false;
|
||||
|
||||
const packages = await Package.searchPackages(query, {
|
||||
protocol,
|
||||
organizationId,
|
||||
isPrivate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
// Filter out packages user doesn't have access to
|
||||
const accessiblePackages: typeof packages = [];
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.isPrivate) {
|
||||
accessiblePackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasIdentity && dataArg.identity) {
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read',
|
||||
);
|
||||
if (canAccess) {
|
||||
accessiblePackages.push(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packages: accessiblePackages.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol as interfaces.data.TRegistryProtocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
latestVersion: pkg.distTags?.['latest'],
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount || 0,
|
||||
starCount: pkg.starCount || 0,
|
||||
storageBytes: pkg.storageBytes || 0,
|
||||
updatedAt: pkg.updatedAt instanceof Date ? pkg.updatedAt.toISOString() : String(pkg.updatedAt),
|
||||
createdAt: pkg.createdAt instanceof Date ? pkg.createdAt.toISOString() : String(pkg.createdAt),
|
||||
})),
|
||||
total: accessiblePackages.length,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to search packages');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Package
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPackage>(
|
||||
'getPackage',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const pkg = await Package.findById(dataArg.packageId);
|
||||
if (!pkg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||
}
|
||||
|
||||
// Check access for private packages
|
||||
if (pkg.isPrivate) {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Authentication required');
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read',
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
package: {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol as interfaces.data.TRegistryProtocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
latestVersion: pkg.distTags?.['latest'],
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount || 0,
|
||||
starCount: pkg.starCount || 0,
|
||||
storageBytes: pkg.storageBytes || 0,
|
||||
distTags: pkg.distTags || {},
|
||||
versions: Object.keys(pkg.versions || {}),
|
||||
updatedAt: pkg.updatedAt instanceof Date ? pkg.updatedAt.toISOString() : String(pkg.updatedAt),
|
||||
createdAt: pkg.createdAt instanceof Date ? pkg.createdAt.toISOString() : String(pkg.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get package');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Package Versions
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPackageVersions>(
|
||||
'getPackageVersions',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const pkg = await Package.findById(dataArg.packageId);
|
||||
if (!pkg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||
}
|
||||
|
||||
// Check access for private packages
|
||||
if (pkg.isPrivate) {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Authentication required');
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read',
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const versions = Object.entries(pkg.versions || {}).map(([version, data]) => ({
|
||||
version,
|
||||
publishedAt: data.publishedAt instanceof Date ? data.publishedAt.toISOString() : String(data.publishedAt || ''),
|
||||
size: data.size || 0,
|
||||
downloads: data.downloads || 0,
|
||||
checksum: data.metadata?.checksum as interfaces.data.IPackageVersion['checksum'],
|
||||
}));
|
||||
|
||||
return {
|
||||
packageId: pkg.id,
|
||||
packageName: pkg.name,
|
||||
distTags: pkg.distTags || {},
|
||||
versions,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list versions');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Package
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePackage>(
|
||||
'deletePackage',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(dataArg.packageId);
|
||||
if (!pkg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete',
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Delete permission required');
|
||||
}
|
||||
|
||||
// Update repository counts before deleting
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.packageCount = Math.max(0, repo.packageCount - 1);
|
||||
repo.storageBytes -= pkg.storageBytes;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
await pkg.delete();
|
||||
|
||||
return { message: 'Package deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete package');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Package Version
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePackageVersion>(
|
||||
'deletePackageVersion',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(dataArg.packageId);
|
||||
if (!pkg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||
}
|
||||
|
||||
const versionData = pkg.versions?.[dataArg.version];
|
||||
if (!versionData) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Version not found');
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete',
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Delete permission required');
|
||||
}
|
||||
|
||||
// Check if this is the only version
|
||||
if (Object.keys(pkg.versions).length === 1) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Cannot delete the only version. Delete the entire package instead.',
|
||||
);
|
||||
}
|
||||
|
||||
// Remove version
|
||||
const sizeReduction = versionData.size || 0;
|
||||
delete pkg.versions[dataArg.version];
|
||||
pkg.storageBytes -= sizeReduction;
|
||||
|
||||
// Update dist tags
|
||||
for (const [tag, tagVersion] of Object.entries(pkg.distTags || {})) {
|
||||
if (tagVersion === dataArg.version) {
|
||||
delete pkg.distTags[tag];
|
||||
}
|
||||
}
|
||||
|
||||
// Set new latest if needed
|
||||
if (!pkg.distTags['latest'] && Object.keys(pkg.versions).length > 0) {
|
||||
const versions = Object.keys(pkg.versions).sort();
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
}
|
||||
|
||||
await pkg.save();
|
||||
|
||||
// Update repository storage
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.storageBytes -= sizeReduction;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
return { message: 'Version deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete version');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
272
ts/opsserver/handlers/repository.handler.ts
Normal file
272
ts/opsserver/handlers/repository.handler.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { Organization, Repository } from '../../models/index.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
|
||||
export class RepositoryHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Repositories
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRepositories>(
|
||||
'getRepositories',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||
dataArg.identity.userId,
|
||||
dataArg.organizationId,
|
||||
);
|
||||
|
||||
return {
|
||||
repositories: repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes || 0,
|
||||
downloadCount: (repo as any).downloadCount || 0,
|
||||
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list repositories');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Repository
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRepository>(
|
||||
'getRepository',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(dataArg.repositoryId);
|
||||
if (!repo) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (!repo.isPublic) {
|
||||
const permissions = await this.permissionService.resolvePermissions({
|
||||
userId: dataArg.identity.userId,
|
||||
organizationId: repo.organizationId,
|
||||
repositoryId: repo.id,
|
||||
});
|
||||
|
||||
if (!permissions.canRead) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repository: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes || 0,
|
||||
downloadCount: (repo as any).downloadCount || 0,
|
||||
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get repository');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create Repository
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRepository>(
|
||||
'createRepository',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { organizationId, name, description, protocol, visibility } = dataArg;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Repository name is required');
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
|
||||
);
|
||||
}
|
||||
|
||||
// Check org exists
|
||||
const org = await Organization.findById(organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Create repository
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId,
|
||||
name,
|
||||
description,
|
||||
protocol: protocol || 'npm',
|
||||
visibility: visibility || 'private',
|
||||
createdById: dataArg.identity.userId,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
organizationId,
|
||||
}).logRepositoryCreated(repo.id, repo.name, organizationId);
|
||||
|
||||
return {
|
||||
repository: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes || 0,
|
||||
downloadCount: 0,
|
||||
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to create repository');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Repository
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRepository>(
|
||||
'updateRepository',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(dataArg.repositoryId);
|
||||
if (!repo) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
dataArg.identity.userId,
|
||||
repo.organizationId,
|
||||
dataArg.repositoryId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
if (dataArg.description !== undefined) repo.description = dataArg.description;
|
||||
if (dataArg.visibility !== undefined) repo.visibility = dataArg.visibility as any;
|
||||
|
||||
await repo.save();
|
||||
|
||||
return {
|
||||
repository: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes || 0,
|
||||
downloadCount: (repo as any).downloadCount || 0,
|
||||
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update repository');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Repository
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRepository>(
|
||||
'deleteRepository',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(dataArg.repositoryId);
|
||||
if (!repo) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
dataArg.identity.userId,
|
||||
repo.organizationId,
|
||||
dataArg.repositoryId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
// Check for packages
|
||||
if (repo.packageCount > 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Cannot delete repository with packages. Remove all packages first.',
|
||||
);
|
||||
}
|
||||
|
||||
await repo.delete();
|
||||
|
||||
return { message: 'Repository deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete repository');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
ts/opsserver/handlers/token.handler.ts
Normal file
198
ts/opsserver/handlers/token.handler.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { ApiToken } from '../../models/index.ts';
|
||||
import { TokenService } from '../../services/token.service.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
|
||||
export class TokenHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private tokenService = new TokenService();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Tokens
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTokens>(
|
||||
'getTokens',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
let tokens;
|
||||
if (dataArg.organizationId) {
|
||||
// Check if user can manage org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
dataArg.organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Not authorized to view organization tokens',
|
||||
);
|
||||
}
|
||||
tokens = await this.tokenService.getOrgTokens(dataArg.organizationId);
|
||||
} else {
|
||||
tokens = await this.tokenService.getUserTokens(dataArg.identity.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
tokens: tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
protocols: t.protocols as interfaces.data.TRegistryProtocol[],
|
||||
scopes: t.scopes as interfaces.data.ITokenScope[],
|
||||
organizationId: t.organizationId,
|
||||
createdById: t.createdById,
|
||||
expiresAt: t.expiresAt instanceof Date ? t.expiresAt.toISOString() : t.expiresAt ? String(t.expiresAt) : undefined,
|
||||
lastUsedAt: t.lastUsedAt instanceof Date ? t.lastUsedAt.toISOString() : t.lastUsedAt ? String(t.lastUsedAt) : undefined,
|
||||
usageCount: t.usageCount,
|
||||
createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list tokens');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create Token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateToken>(
|
||||
'createToken',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { name, organizationId, protocols, scopes, expiresInDays } = dataArg;
|
||||
|
||||
if (!name) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Token name is required');
|
||||
}
|
||||
|
||||
if (!protocols || protocols.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('At least one protocol is required');
|
||||
}
|
||||
|
||||
if (!scopes || scopes.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('At least one scope is required');
|
||||
}
|
||||
|
||||
// Validate protocols
|
||||
const validProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems', '*'];
|
||||
for (const protocol of protocols) {
|
||||
if (!validProtocols.includes(protocol)) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Invalid protocol: ${protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
for (const scope of scopes) {
|
||||
if (!scope.protocol || !scope.actions || scope.actions.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid scope configuration');
|
||||
}
|
||||
}
|
||||
|
||||
// If creating org token, verify permission
|
||||
if (organizationId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Not authorized to create organization tokens',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.tokenService.createToken({
|
||||
userId: dataArg.identity.userId,
|
||||
organizationId,
|
||||
createdById: dataArg.identity.userId,
|
||||
name,
|
||||
protocols: protocols as any[],
|
||||
scopes: scopes as any[],
|
||||
expiresInDays,
|
||||
});
|
||||
|
||||
return {
|
||||
token: {
|
||||
id: result.token.id,
|
||||
name: result.token.name,
|
||||
token: result.rawToken,
|
||||
tokenPrefix: result.token.tokenPrefix,
|
||||
protocols: result.token.protocols as interfaces.data.TRegistryProtocol[],
|
||||
scopes: result.token.scopes as interfaces.data.ITokenScope[],
|
||||
organizationId: result.token.organizationId,
|
||||
createdById: result.token.createdById,
|
||||
expiresAt: result.token.expiresAt instanceof Date ? result.token.expiresAt.toISOString() : result.token.expiresAt ? String(result.token.expiresAt) : undefined,
|
||||
usageCount: result.token.usageCount,
|
||||
createdAt: result.token.createdAt instanceof Date ? result.token.createdAt.toISOString() : String(result.token.createdAt),
|
||||
warning: 'Store this token securely. It will not be shown again.',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to create token');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Revoke Token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeToken>(
|
||||
'revokeToken',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { tokenId } = dataArg;
|
||||
|
||||
// Check if it's a personal token
|
||||
const userTokens = await this.tokenService.getUserTokens(dataArg.identity.userId);
|
||||
let token = userTokens.find((t) => t.id === tokenId);
|
||||
|
||||
if (!token) {
|
||||
// Check if it's an org token the user can manage
|
||||
const anyToken = await ApiToken.getInstance({ id: tokenId, isRevoked: false });
|
||||
if (anyToken?.organizationId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
anyToken.organizationId,
|
||||
);
|
||||
if (canManage) {
|
||||
token = anyToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Token not found');
|
||||
}
|
||||
|
||||
const success = await this.tokenService.revokeToken(tokenId, 'user_revoked');
|
||||
if (!success) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to revoke token');
|
||||
}
|
||||
|
||||
return { message: 'Token revoked successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to revoke token');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
ts/opsserver/handlers/user.handler.ts
Normal file
263
ts/opsserver/handlers/user.handler.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity, requireAdminIdentity } from '../helpers/guards.ts';
|
||||
import { User } from '../../models/user.ts';
|
||||
import { Session } from '../../models/session.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
|
||||
export class UserHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private authService = new AuthService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to format user to IUser interface
|
||||
*/
|
||||
private formatUser(user: User): interfaces.data.IUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
|
||||
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Users (admin only)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUsers>(
|
||||
'getUsers',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const users = await User.getInstances({});
|
||||
return {
|
||||
users: users.map((u) => this.formatUser(u)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list users');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get User
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUser>(
|
||||
'getUser',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { userId } = dataArg;
|
||||
|
||||
// Users can view their own profile, admins can view any
|
||||
if (userId !== dataArg.identity.userId && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
return { user: this.formatUser(user) };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get user');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update User
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateUser>(
|
||||
'updateUser',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { userId, displayName, avatarUrl, password, isActive, isSystemAdmin } = dataArg;
|
||||
|
||||
// Users can update their own profile, admins can update any
|
||||
if (userId !== dataArg.identity.userId && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
if (displayName !== undefined) user.displayName = displayName;
|
||||
if (avatarUrl !== undefined) user.avatarUrl = avatarUrl;
|
||||
|
||||
// Only admins can change these
|
||||
if (dataArg.identity.isSystemAdmin) {
|
||||
if (isActive !== undefined) user.status = isActive ? 'active' : 'suspended';
|
||||
if (isSystemAdmin !== undefined) user.isPlatformAdmin = isSystemAdmin;
|
||||
}
|
||||
|
||||
// Password change
|
||||
if (password) {
|
||||
user.passwordHash = await AuthService.hashPassword(password);
|
||||
}
|
||||
|
||||
await user.save();
|
||||
|
||||
return { user: this.formatUser(user) };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update user');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get User Sessions
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUserSessions>(
|
||||
'getUserSessions',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const sessions = await Session.getUserSessions(dataArg.identity.userId);
|
||||
|
||||
return {
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
userId: s.userId,
|
||||
userAgent: s.userAgent,
|
||||
ipAddress: s.ipAddress,
|
||||
isValid: s.isValid,
|
||||
lastActivityAt: s.lastActivityAt instanceof Date ? s.lastActivityAt.toISOString() : String(s.lastActivityAt),
|
||||
createdAt: s.createdAt instanceof Date ? s.createdAt.toISOString() : String(s.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get sessions');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Revoke Session
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeSession>(
|
||||
'revokeSession',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
await this.authService.logout(dataArg.sessionId, {
|
||||
userId: dataArg.identity.userId,
|
||||
});
|
||||
|
||||
return { message: 'Session revoked successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to revoke session');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Change Password
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
|
||||
'changePassword',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { currentPassword, newPassword } = dataArg;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Current password and new password are required',
|
||||
);
|
||||
}
|
||||
|
||||
const user = await User.findById(dataArg.identity.userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValid = await user.verifyPassword(currentPassword);
|
||||
if (!isValid) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash and set new password
|
||||
user.passwordHash = await AuthService.hashPassword(newPassword);
|
||||
await user.save();
|
||||
|
||||
return { message: 'Password changed successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to change password');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Account
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAccount>(
|
||||
'deleteAccount',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { password } = dataArg;
|
||||
|
||||
if (!password) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Password is required to delete account',
|
||||
);
|
||||
}
|
||||
|
||||
const user = await User.findById(dataArg.identity.userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await user.verifyPassword(password);
|
||||
if (!isValid) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Password is incorrect');
|
||||
}
|
||||
|
||||
// Soft delete - deactivate instead of removing
|
||||
user.status = 'suspended';
|
||||
await user.save();
|
||||
|
||||
// Invalidate all sessions
|
||||
await Session.invalidateAllUserSessions(user.id, 'account_deleted');
|
||||
|
||||
return { message: 'Account deactivated successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete account');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
ts/opsserver/helpers/guards.ts
Normal file
29
ts/opsserver/helpers/guards.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { AuthHandler } from '../handlers/auth.handler.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
|
||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
authHandler: AuthHandler,
|
||||
dataArg: T,
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
const passed = await authHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
authHandler: AuthHandler,
|
||||
dataArg: T,
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
const passed = await authHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user