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:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.4.2',
|
||||
version: '1.5.0',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
|
||||
@@ -113,7 +113,9 @@ export class AdminAuthApi {
|
||||
});
|
||||
} else if (body.type === 'ldap' && body.ldapConfig) {
|
||||
// Encrypt bind password
|
||||
const encryptedPassword = await cryptoService.encrypt(body.ldapConfig.bindPasswordEncrypted);
|
||||
const encryptedPassword = await cryptoService.encrypt(
|
||||
body.ldapConfig.bindPasswordEncrypted,
|
||||
);
|
||||
|
||||
provider = await AuthProvider.createLdapProvider({
|
||||
name: body.name,
|
||||
@@ -228,7 +230,7 @@ export class AdminAuthApi {
|
||||
!cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted)
|
||||
) {
|
||||
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
||||
body.oauthConfig.clientSecretEncrypted
|
||||
body.oauthConfig.clientSecretEncrypted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +247,7 @@ export class AdminAuthApi {
|
||||
!cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted)
|
||||
) {
|
||||
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
||||
body.ldapConfig.bindPasswordEncrypted
|
||||
body.ldapConfig.bindPasswordEncrypted,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ export class AuditApi {
|
||||
// Parse query parameters
|
||||
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
||||
const repositoryId = ctx.url.searchParams.get('repositoryId') || undefined;
|
||||
const resourceType = ctx.url.searchParams.get('resourceType') as TAuditResourceType | undefined;
|
||||
const resourceType = ctx.url.searchParams.get('resourceType') as
|
||||
| TAuditResourceType
|
||||
| undefined;
|
||||
const actionsParam = ctx.url.searchParams.get('actions');
|
||||
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
|
||||
const success = ctx.url.searchParams.has('success')
|
||||
@@ -54,7 +56,7 @@ export class AuditApi {
|
||||
// Check if user can manage this org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
// User can only see their own actions in this org
|
||||
|
||||
@@ -93,7 +93,7 @@ export class OAuthApi {
|
||||
|
||||
const result = await externalAuthService.handleOAuthCallback(
|
||||
{ code, state },
|
||||
{ ipAddress: ctx.ip, userAgent: ctx.userAgent }
|
||||
{ ipAddress: ctx.ip, userAgent: ctx.userAgent },
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -208,7 +208,10 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check admin permission using org.id
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
@@ -319,13 +322,13 @@ export class OrganizationApi {
|
||||
addedAt: m.joinedAt,
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -356,7 +359,10 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
@@ -431,7 +437,10 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
@@ -492,7 +501,10 @@ export class OrganizationApi {
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
'read',
|
||||
);
|
||||
if (canAccess) {
|
||||
accessiblePackages.push(pkg);
|
||||
@@ -106,7 +106,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
'read',
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
@@ -161,7 +161,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
'read',
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
@@ -213,7 +213,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
'delete',
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
@@ -267,7 +267,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
'delete',
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import { Repository, Organization } from '../../models/index.ts';
|
||||
import { Organization, Repository } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol, TRepositoryVisibility } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class RepositoryApi {
|
||||
@@ -28,7 +28,7 @@ export class RepositoryApi {
|
||||
try {
|
||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||
ctx.actor.userId,
|
||||
orgId
|
||||
orgId,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -131,7 +131,10 @@ export class RepositoryApi {
|
||||
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores' },
|
||||
body: {
|
||||
error:
|
||||
'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,7 +201,7 @@ export class RepositoryApi {
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
@@ -252,7 +255,7 @@ export class RepositoryApi {
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
|
||||
@@ -33,7 +33,10 @@ export class TokenApi {
|
||||
let tokens;
|
||||
if (organizationId) {
|
||||
// Check if user can manage org
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Not authorized to view organization tokens' } };
|
||||
}
|
||||
@@ -119,7 +122,10 @@ export class TokenApi {
|
||||
|
||||
// If creating org token, verify permission
|
||||
if (organizationId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Not authorized to create organization tokens' } };
|
||||
}
|
||||
@@ -181,7 +187,7 @@ export class TokenApi {
|
||||
if (anyToken?.organizationId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
anyToken.organizationId
|
||||
anyToken.organizationId,
|
||||
);
|
||||
if (canManage) {
|
||||
token = anyToken;
|
||||
|
||||
@@ -104,24 +104,56 @@ export class ApiRouter {
|
||||
this.addRoute('POST', '/api/v1/organizations', (ctx) => this.organizationApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/organizations/:id', (ctx) => this.organizationApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/organizations/:id', (ctx) => this.organizationApi.delete(ctx));
|
||||
this.addRoute('GET', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.listMembers(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.addMember(ctx));
|
||||
this.addRoute('PUT', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.updateMember(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.removeMember(ctx));
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/organizations/:id/members',
|
||||
(ctx) => this.organizationApi.listMembers(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'POST',
|
||||
'/api/v1/organizations/:id/members',
|
||||
(ctx) => this.organizationApi.addMember(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'PUT',
|
||||
'/api/v1/organizations/:id/members/:userId',
|
||||
(ctx) => this.organizationApi.updateMember(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'DELETE',
|
||||
'/api/v1/organizations/:id/members/:userId',
|
||||
(ctx) => this.organizationApi.removeMember(ctx),
|
||||
);
|
||||
|
||||
// Repository routes
|
||||
this.addRoute('GET', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.list(ctx));
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/organizations/:orgId/repositories',
|
||||
(ctx) => this.repositoryApi.list(ctx),
|
||||
);
|
||||
this.addRoute('GET', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.create(ctx));
|
||||
this.addRoute(
|
||||
'POST',
|
||||
'/api/v1/organizations/:orgId/repositories',
|
||||
(ctx) => this.repositoryApi.create(ctx),
|
||||
);
|
||||
this.addRoute('PUT', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
|
||||
|
||||
// Package routes
|
||||
this.addRoute('GET', '/api/v1/packages', (ctx) => this.packageApi.search(ctx));
|
||||
this.addRoute('GET', '/api/v1/packages/:id', (ctx) => this.packageApi.get(ctx));
|
||||
this.addRoute('GET', '/api/v1/packages/:id/versions', (ctx) => this.packageApi.listVersions(ctx));
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/packages/:id/versions',
|
||||
(ctx) => this.packageApi.listVersions(ctx),
|
||||
);
|
||||
this.addRoute('DELETE', '/api/v1/packages/:id', (ctx) => this.packageApi.delete(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/packages/:id/versions/:version', (ctx) => this.packageApi.deleteVersion(ctx));
|
||||
this.addRoute(
|
||||
'DELETE',
|
||||
'/api/v1/packages/:id/versions/:version',
|
||||
(ctx) => this.packageApi.deleteVersion(ctx),
|
||||
);
|
||||
|
||||
// Token routes
|
||||
this.addRoute('GET', '/api/v1/tokens', (ctx) => this.tokenApi.list(ctx));
|
||||
@@ -138,14 +170,46 @@ export class ApiRouter {
|
||||
this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx));
|
||||
|
||||
// Admin auth routes (platform admin only)
|
||||
this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx));
|
||||
this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx));
|
||||
this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx));
|
||||
this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.deleteProvider(ctx));
|
||||
this.addRoute('POST', '/api/v1/admin/auth/providers/:id/test', (ctx) => this.adminAuthApi.testProvider(ctx));
|
||||
this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx));
|
||||
this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx));
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/admin/auth/providers',
|
||||
(ctx) => this.adminAuthApi.listProviders(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'POST',
|
||||
'/api/v1/admin/auth/providers',
|
||||
(ctx) => this.adminAuthApi.createProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/admin/auth/providers/:id',
|
||||
(ctx) => this.adminAuthApi.getProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'PUT',
|
||||
'/api/v1/admin/auth/providers/:id',
|
||||
(ctx) => this.adminAuthApi.updateProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'DELETE',
|
||||
'/api/v1/admin/auth/providers/:id',
|
||||
(ctx) => this.adminAuthApi.deleteProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'POST',
|
||||
'/api/v1/admin/auth/providers/:id/test',
|
||||
(ctx) => this.adminAuthApi.testProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/admin/auth/settings',
|
||||
(ctx) => this.adminAuthApi.getSettings(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'PUT',
|
||||
'/api/v1/admin/auth/settings',
|
||||
(ctx) => this.adminAuthApi.updateSettings(ctx),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
12
ts/cli.ts
12
ts/cli.ts
@@ -3,9 +3,13 @@
|
||||
*/
|
||||
|
||||
import * as plugins from './plugins.ts';
|
||||
import { StackGalleryRegistry, createRegistryFromEnv, createRegistryFromEnvFile } from './registry.ts';
|
||||
import {
|
||||
createRegistryFromEnv,
|
||||
createRegistryFromEnvFile,
|
||||
StackGalleryRegistry,
|
||||
} from './registry.ts';
|
||||
import { initDb } from './models/db.ts';
|
||||
import { User, Organization, OrganizationMember, Repository } from './models/index.ts';
|
||||
import { Organization, OrganizationMember, Repository, User } from './models/index.ts';
|
||||
import { AuthService } from './services/auth.service.ts';
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
@@ -21,9 +25,7 @@ export async function runCli(): Promise<void> {
|
||||
}
|
||||
|
||||
// Use env file in ephemeral/dev mode, otherwise use environment variables
|
||||
const registry = isEphemeral
|
||||
? await createRegistryFromEnvFile()
|
||||
: createRegistryFromEnv();
|
||||
const registry = isEphemeral ? await createRegistryFromEnvFile() : createRegistryFromEnv();
|
||||
await registry.start();
|
||||
|
||||
// Handle shutdown gracefully
|
||||
|
||||
@@ -103,7 +103,14 @@ export interface ITeamMember {
|
||||
|
||||
export type TRepositoryVisibility = 'public' | 'private' | 'internal';
|
||||
export type TRepositoryRole = 'admin' | 'maintainer' | 'developer' | 'reader';
|
||||
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems';
|
||||
export type TRegistryProtocol =
|
||||
| 'oci'
|
||||
| 'npm'
|
||||
| 'maven'
|
||||
| 'cargo'
|
||||
| 'composer'
|
||||
| 'pypi'
|
||||
| 'rubygems';
|
||||
|
||||
export interface IRepository {
|
||||
id: string;
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { IApiToken, ITokenScope, TRegistryProtocol } from '../interfaces/au
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken> implements IApiToken {
|
||||
export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken>
|
||||
implements IApiToken {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -150,7 +151,7 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
||||
protocol: TRegistryProtocol,
|
||||
organizationId?: string,
|
||||
repositoryId?: string,
|
||||
action?: string
|
||||
action?: string,
|
||||
): boolean {
|
||||
for (const scope of this.scopes) {
|
||||
// Check protocol
|
||||
@@ -163,7 +164,9 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
||||
if (scope.repositoryId && scope.repositoryId !== repositoryId) continue;
|
||||
|
||||
// Check action
|
||||
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) continue;
|
||||
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IAuditLog, TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts';
|
||||
import type {
|
||||
IAuditLog,
|
||||
TAuditAction,
|
||||
TAuditResourceType,
|
||||
} from '../interfaces/audit.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class AuditLog extends plugins.smartdata.SmartDataDbDoc<AuditLog, AuditLog> implements IAuditLog {
|
||||
export class AuditLog extends plugins.smartdata.SmartDataDbDoc<AuditLog, AuditLog>
|
||||
implements IAuditLog {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IAuthProvider,
|
||||
TAuthProviderType,
|
||||
TAuthProviderStatus,
|
||||
IOAuthConfig,
|
||||
ILdapConfig,
|
||||
IAttributeMapping,
|
||||
IAuthProvider,
|
||||
ILdapConfig,
|
||||
IOAuthConfig,
|
||||
IProvisioningSettings,
|
||||
TAuthProviderStatus,
|
||||
TAuthProviderType,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@@ -27,10 +27,8 @@ const DEFAULT_PROVISIONING: IProvisioningSettings = {
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class AuthProvider
|
||||
extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
|
||||
implements IAuthProvider
|
||||
{
|
||||
export class AuthProvider extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
|
||||
implements IAuthProvider {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ let isInitialized = false;
|
||||
*/
|
||||
export async function initDb(
|
||||
mongoDbUrl: string,
|
||||
mongoDbName?: string
|
||||
mongoDbName?: string,
|
||||
): Promise<plugins.smartdata.SmartdataDb> {
|
||||
if (isInitialized && db) {
|
||||
return db;
|
||||
|
||||
@@ -10,8 +10,7 @@ import { db } from './db.ts';
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class ExternalIdentity
|
||||
extends plugins.smartdata.SmartDataDbDoc<ExternalIdentity, ExternalIdentity>
|
||||
implements IExternalIdentity
|
||||
{
|
||||
implements IExternalIdentity {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -55,7 +54,7 @@ export class ExternalIdentity
|
||||
*/
|
||||
public static async findByExternalId(
|
||||
providerId: string,
|
||||
externalId: string
|
||||
externalId: string,
|
||||
): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ providerId, externalId });
|
||||
}
|
||||
@@ -72,7 +71,7 @@ export class ExternalIdentity
|
||||
*/
|
||||
public static async findByUserAndProvider(
|
||||
userId: string,
|
||||
providerId: string
|
||||
providerId: string,
|
||||
): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ userId, providerId });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Model exports
|
||||
*/
|
||||
|
||||
export { initDb, getDb, closeDb, isDbConnected } from './db.ts';
|
||||
export { closeDb, getDb, initDb, isDbConnected } from './db.ts';
|
||||
export { User } from './user.ts';
|
||||
export { Organization } from './organization.ts';
|
||||
export { OrganizationMember } from './organization.member.ts';
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { IOrganizationMember, TOrganizationRole } from '../interfaces/auth.
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class OrganizationMember extends plugins.smartdata.SmartDataDbDoc<OrganizationMember, OrganizationMember> implements IOrganizationMember {
|
||||
export class OrganizationMember
|
||||
extends plugins.smartdata.SmartDataDbDoc<OrganizationMember, OrganizationMember>
|
||||
implements IOrganizationMember {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -69,7 +71,7 @@ export class OrganizationMember extends plugins.smartdata.SmartDataDbDoc<Organiz
|
||||
*/
|
||||
public static async findMembership(
|
||||
organizationId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<OrganizationMember | null> {
|
||||
return await OrganizationMember.getInstance({
|
||||
organizationId,
|
||||
|
||||
@@ -18,7 +18,8 @@ const DEFAULT_SETTINGS: IOrganizationSettings = {
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization, Organization> implements IOrganization {
|
||||
export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization, Organization>
|
||||
implements IOrganization {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -92,7 +93,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name)) {
|
||||
throw new Error(
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens and dots'
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens and dots',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package> implements IPackage {
|
||||
export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
implements IPackage {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = ''; // {protocol}:{org}:{name}
|
||||
|
||||
@@ -94,7 +95,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
public static async findByName(
|
||||
protocol: TRegistryProtocol,
|
||||
orgName: string,
|
||||
name: string
|
||||
name: string,
|
||||
): Promise<Package | null> {
|
||||
const id = Package.generateId(protocol, orgName, name);
|
||||
return await Package.findById(id);
|
||||
@@ -118,7 +119,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
isPrivate?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
},
|
||||
): Promise<Package[]> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (options?.protocol) filter.protocol = options.protocol;
|
||||
@@ -133,7 +134,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
const filtered = allPackages.filter(
|
||||
(pkg) =>
|
||||
pkg.name.toLowerCase().includes(lowerQuery) ||
|
||||
pkg.description?.toLowerCase().includes(lowerQuery)
|
||||
pkg.description?.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
|
||||
// Apply pagination
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IPlatformSettings, IPlatformAuthSettings } from '../interfaces/auth.interfaces.ts';
|
||||
import type { IPlatformAuthSettings, IPlatformSettings } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
||||
@@ -16,8 +16,7 @@ const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class PlatformSettings
|
||||
extends plugins.smartdata.SmartDataDbDoc<PlatformSettings, PlatformSettings>
|
||||
implements IPlatformSettings
|
||||
{
|
||||
implements IPlatformSettings {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = 'singleton';
|
||||
|
||||
@@ -51,7 +50,7 @@ export class PlatformSettings
|
||||
*/
|
||||
public async updateAuthSettings(
|
||||
settings: Partial<IPlatformAuthSettings>,
|
||||
updatedById?: string
|
||||
updatedById?: string,
|
||||
): Promise<void> {
|
||||
this.auth = { ...this.auth, ...settings };
|
||||
this.updatedAt = new Date();
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { IRepositoryPermission, TRepositoryRole } from '../interfaces/auth.
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<RepositoryPermission, RepositoryPermission> implements IRepositoryPermission {
|
||||
export class RepositoryPermission
|
||||
extends plugins.smartdata.SmartDataDbDoc<RepositoryPermission, RepositoryPermission>
|
||||
implements IRepositoryPermission {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -104,7 +106,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
||||
*/
|
||||
public static async findPermission(
|
||||
repositoryId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getUserPermission(repositoryId, userId);
|
||||
}
|
||||
@@ -114,7 +116,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
||||
*/
|
||||
public static async getUserPermission(
|
||||
repositoryId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getInstance({
|
||||
repositoryId,
|
||||
@@ -127,7 +129,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
||||
*/
|
||||
public static async getTeamPermission(
|
||||
repositoryId: string,
|
||||
teamId: string
|
||||
teamId: string,
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getInstance({
|
||||
repositoryId,
|
||||
@@ -149,7 +151,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
||||
*/
|
||||
public static async getTeamPermissionsForRepo(
|
||||
repositoryId: string,
|
||||
teamIds: string[]
|
||||
teamIds: string[],
|
||||
): Promise<RepositoryPermission[]> {
|
||||
if (teamIds.length === 0) return [];
|
||||
return await RepositoryPermission.getInstances({
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IRepository, TRepositoryVisibility, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import type {
|
||||
IRepository,
|
||||
TRegistryProtocol,
|
||||
TRepositoryVisibility,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository> implements IRepository {
|
||||
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository>
|
||||
implements IRepository {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -70,7 +75,9 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
// Validate name
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name.toLowerCase())) {
|
||||
throw new Error('Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores');
|
||||
throw new Error(
|
||||
'Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate name in org + protocol
|
||||
@@ -105,7 +112,7 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
public static async findByName(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
protocol: TRegistryProtocol
|
||||
protocol: TRegistryProtocol,
|
||||
): Promise<Repository | null> {
|
||||
return await Repository.getInstance({
|
||||
organizationId,
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ISession } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session> implements ISession {
|
||||
export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session>
|
||||
implements ISession {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -94,7 +95,7 @@ export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session>
|
||||
*/
|
||||
public static async invalidateAllUserSessions(
|
||||
userId: string,
|
||||
reason: string = 'logout_all'
|
||||
reason: string = 'logout_all',
|
||||
): Promise<number> {
|
||||
const sessions = await Session.getUserSessions(userId);
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ITeamMember, TTeamRole } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class TeamMember extends plugins.smartdata.SmartDataDbDoc<TeamMember, TeamMember> implements ITeamMember {
|
||||
export class TeamMember extends plugins.smartdata.SmartDataDbDoc<TeamMember, TeamMember>
|
||||
implements ITeamMember {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import * as smartunique from '@push.rocks/smartunique';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartcli from '@push.rocks/smartcli';
|
||||
import * as smartguard from '@push.rocks/smartguard';
|
||||
|
||||
// api.global packages
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
// tsclass types
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
@@ -28,25 +32,28 @@ import * as fs from '@std/fs';
|
||||
import * as http from '@std/http';
|
||||
|
||||
export {
|
||||
// Push.rocks
|
||||
smartregistry,
|
||||
smartdata,
|
||||
smartbucket,
|
||||
smartlog,
|
||||
smartenv,
|
||||
smartpath,
|
||||
smartpromise,
|
||||
smartstring,
|
||||
smartcrypto,
|
||||
smartjwt,
|
||||
smartunique,
|
||||
smartdelay,
|
||||
smartrx,
|
||||
smartcli,
|
||||
// tsclass
|
||||
tsclass,
|
||||
// Deno std
|
||||
path,
|
||||
fs,
|
||||
http,
|
||||
// Deno std
|
||||
path,
|
||||
smartbucket,
|
||||
smartcli,
|
||||
smartcrypto,
|
||||
smartdata,
|
||||
smartdelay,
|
||||
smartenv,
|
||||
smartguard,
|
||||
smartjwt,
|
||||
smartlog,
|
||||
smartpath,
|
||||
smartpromise,
|
||||
// Push.rocks
|
||||
smartregistry,
|
||||
smartrx,
|
||||
smartstring,
|
||||
smartunique,
|
||||
// tsclass
|
||||
tsclass,
|
||||
// api.global
|
||||
typedrequest,
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
||||
* Returns userId on success, null on failure
|
||||
*/
|
||||
public async authenticate(
|
||||
credentials: plugins.smartregistry.ICredentials
|
||||
credentials: plugins.smartregistry.ICredentials,
|
||||
): Promise<string | null> {
|
||||
const result = await this.authService.login(credentials.username, credentials.password);
|
||||
if (!result.success || !result.user) return null;
|
||||
@@ -62,7 +62,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
||||
*/
|
||||
public async validateToken(
|
||||
token: string,
|
||||
protocol?: plugins.smartregistry.TRegistryProtocol
|
||||
protocol?: plugins.smartregistry.TRegistryProtocol,
|
||||
): Promise<plugins.smartregistry.IAuthToken | null> {
|
||||
// Try API token (srg_ prefix)
|
||||
if (token.startsWith('srg_')) {
|
||||
@@ -70,11 +70,10 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
||||
if (!result.valid || !result.token || !result.user) return null;
|
||||
|
||||
return {
|
||||
type: (protocol || result.token.protocols[0] || 'npm') as plugins.smartregistry.TRegistryProtocol,
|
||||
type: (protocol || result.token.protocols[0] ||
|
||||
'npm') as plugins.smartregistry.TRegistryProtocol,
|
||||
userId: result.user.id,
|
||||
scopes: result.token.scopes.map((s) =>
|
||||
`${s.protocol}:${s.actions.join(',')}`
|
||||
),
|
||||
scopes: result.token.scopes.map((s) => `${s.protocol}:${s.actions.join(',')}`),
|
||||
readonly: !result.token.scopes.some((s) =>
|
||||
s.actions.includes('write') || s.actions.includes('*')
|
||||
),
|
||||
@@ -98,7 +97,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
||||
public async createToken(
|
||||
userId: string,
|
||||
protocol: plugins.smartregistry.TRegistryProtocol,
|
||||
options?: plugins.smartregistry.ITokenOptions
|
||||
options?: plugins.smartregistry.ITokenOptions,
|
||||
): Promise<string> {
|
||||
const result = await this.tokenService.createToken({
|
||||
userId,
|
||||
@@ -133,7 +132,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
||||
public async authorize(
|
||||
token: plugins.smartregistry.IAuthToken | null,
|
||||
resource: string,
|
||||
action: string
|
||||
action: string,
|
||||
): Promise<boolean> {
|
||||
// Anonymous access: only public reads
|
||||
if (!token) return false;
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
* Provider exports
|
||||
*/
|
||||
|
||||
export { StackGalleryAuthProvider, type IStackGalleryActor } from './auth.provider.ts';
|
||||
export { StackGalleryStorageHooks, type IStorageConfig } from './storage.provider.ts';
|
||||
export { type IStackGalleryActor, StackGalleryAuthProvider } from './auth.provider.ts';
|
||||
export { type IStorageConfig, StackGalleryStorageHooks } from './storage.provider.ts';
|
||||
|
||||
@@ -30,7 +30,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
* Called before a package is stored
|
||||
*/
|
||||
public async beforePut(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<plugins.smartregistry.IBeforePutResult> {
|
||||
// Validate organization exists and has quota
|
||||
const orgId = context.actor?.orgId;
|
||||
@@ -54,7 +54,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
* Called after a package is successfully stored
|
||||
*/
|
||||
public async afterPut(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
@@ -115,7 +115,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
* Called after a package is fetched
|
||||
*/
|
||||
public async afterGet(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
@@ -134,7 +134,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
* Called before a package is deleted
|
||||
*/
|
||||
public async beforeDelete(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
|
||||
return { allowed: true };
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
* Called after a package is deleted
|
||||
*/
|
||||
public async afterDelete(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
@@ -216,7 +216,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
organizationName: string,
|
||||
packageName: string,
|
||||
version: string,
|
||||
filename: string
|
||||
filename: string,
|
||||
): string {
|
||||
return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`;
|
||||
}
|
||||
@@ -227,7 +227,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
public async storeArtifact(
|
||||
path: string,
|
||||
data: Uint8Array,
|
||||
contentType?: string
|
||||
contentType?: string,
|
||||
): Promise<string> {
|
||||
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
||||
await bucket.fastPut({
|
||||
|
||||
137
ts/registry.ts
137
ts/registry.ts
@@ -4,12 +4,46 @@
|
||||
*/
|
||||
|
||||
import * as plugins from './plugins.ts';
|
||||
import { initDb, closeDb, isDbConnected } from './models/db.ts';
|
||||
import { closeDb, initDb, isDbConnected } from './models/db.ts';
|
||||
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
||||
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
||||
import { ApiRouter } from './api/router.ts';
|
||||
import { getEmbeddedFile } from './embedded-ui.generated.ts';
|
||||
import { ReloadSocketManager } from './reload-socket.ts';
|
||||
import { OpsServer } from './opsserver/classes.opsserver.ts';
|
||||
|
||||
// Bundled UI files (generated by tsbundle with base64ts output mode)
|
||||
let bundledFileMap: Map<string, { data: Uint8Array; contentType: string }> | null = null;
|
||||
try {
|
||||
// @ts-ignore - generated file may not exist yet
|
||||
const { files } = await import('../ts_bundled/bundle.ts');
|
||||
bundledFileMap = new Map();
|
||||
for (const file of files as Array<{ path: string; contentBase64: string }>) {
|
||||
const binary = Uint8Array.from(atob(file.contentBase64), (c) => c.charCodeAt(0));
|
||||
const ext = file.path.split('.').pop() || '';
|
||||
bundledFileMap.set(`/${file.path}`, { data: binary, contentType: getContentType(ext) });
|
||||
}
|
||||
} catch {
|
||||
console.warn('[StackGalleryRegistry] No bundled UI found (ts_bundled/bundle.ts missing)');
|
||||
}
|
||||
|
||||
function getContentType(ext: string): string {
|
||||
const types: Record<string, string> = {
|
||||
html: 'text/html',
|
||||
js: 'application/javascript',
|
||||
css: 'text/css',
|
||||
json: 'application/json',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
ttf: 'font/ttf',
|
||||
eot: 'application/vnd.ms-fontobject',
|
||||
map: 'application/json',
|
||||
};
|
||||
return types[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export interface IRegistryConfig {
|
||||
// MongoDB configuration
|
||||
@@ -42,8 +76,7 @@ export class StackGalleryRegistry {
|
||||
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
|
||||
private authProvider: StackGalleryAuthProvider | null = null;
|
||||
private storageHooks: StackGalleryStorageHooks | null = null;
|
||||
private apiRouter: ApiRouter | null = null;
|
||||
private reloadSocket: ReloadSocketManager | null = null;
|
||||
private opsServer: OpsServer | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: IRegistryConfig) {
|
||||
@@ -115,13 +148,11 @@ export class StackGalleryRegistry {
|
||||
});
|
||||
console.log('[StackGalleryRegistry] smartregistry initialized');
|
||||
|
||||
// Initialize API router
|
||||
console.log('[StackGalleryRegistry] Initializing API router...');
|
||||
this.apiRouter = new ApiRouter();
|
||||
console.log('[StackGalleryRegistry] API router initialized');
|
||||
|
||||
// Initialize reload socket for hot reload
|
||||
this.reloadSocket = new ReloadSocketManager();
|
||||
// Initialize OpsServer (TypedRequest handlers)
|
||||
console.log('[StackGalleryRegistry] Initializing OpsServer...');
|
||||
this.opsServer = new OpsServer(this);
|
||||
await this.opsServer.start();
|
||||
console.log('[StackGalleryRegistry] OpsServer initialized');
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[StackGalleryRegistry] Initialization complete');
|
||||
@@ -144,7 +175,7 @@ export class StackGalleryRegistry {
|
||||
{ port, hostname: host },
|
||||
async (request: Request): Promise<Response> => {
|
||||
return await this.handleRequest(request);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
|
||||
@@ -162,11 +193,14 @@ export class StackGalleryRegistry {
|
||||
return this.healthCheck();
|
||||
}
|
||||
|
||||
// API endpoints (handled by REST API layer)
|
||||
if (path.startsWith('/api/')) {
|
||||
return await this.handleApiRequest(request);
|
||||
// TypedRequest endpoint (handled by OpsServer TypedRouter)
|
||||
if (path === '/typedrequest' && request.method === 'POST') {
|
||||
return await this.handleTypedRequest(request);
|
||||
}
|
||||
|
||||
// Legacy REST API endpoints (keep for backwards compatibility during migration)
|
||||
// TODO: Remove once frontend is fully migrated to TypedRequest
|
||||
|
||||
// Registry protocol endpoints (handled by smartregistry)
|
||||
const registryPaths = [
|
||||
'/-/',
|
||||
@@ -180,8 +214,7 @@ export class StackGalleryRegistry {
|
||||
'/api/v1/gems/',
|
||||
'/gems/',
|
||||
];
|
||||
const isRegistryPath =
|
||||
registryPaths.some((p) => path.startsWith(p)) ||
|
||||
const isRegistryPath = registryPaths.some((p) => path.startsWith(p)) ||
|
||||
(path.startsWith('/@') && !path.startsWith('/@stack'));
|
||||
|
||||
if (this.smartRegistry && isRegistryPath) {
|
||||
@@ -199,11 +232,6 @@ export class StackGalleryRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket upgrade for hot reload
|
||||
if (path === '/ws/reload' && request.headers.get('upgrade') === 'websocket') {
|
||||
return this.reloadSocket!.handleUpgrade(request);
|
||||
}
|
||||
|
||||
// Serve static UI files
|
||||
return this.serveStaticFile(path);
|
||||
}
|
||||
@@ -212,7 +240,7 @@ export class StackGalleryRegistry {
|
||||
* Convert a Deno Request to smartregistry IRequestContext
|
||||
*/
|
||||
private async requestToContext(
|
||||
request: Request
|
||||
request: Request,
|
||||
): Promise<plugins.smartregistry.IRequestContext> {
|
||||
const url = new URL(request.url);
|
||||
const headers: Record<string, string> = {};
|
||||
@@ -285,24 +313,28 @@ export class StackGalleryRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static files from embedded UI
|
||||
* Serve static files from bundled UI
|
||||
*/
|
||||
private serveStaticFile(path: string): Response {
|
||||
if (!bundledFileMap) {
|
||||
return new Response('UI not bundled. Run tsbundle first.', { status: 404 });
|
||||
}
|
||||
|
||||
const filePath = path === '/' ? '/index.html' : path;
|
||||
|
||||
// Get embedded file
|
||||
const embeddedFile = getEmbeddedFile(filePath);
|
||||
if (embeddedFile) {
|
||||
return new Response(embeddedFile.data as unknown as BodyInit, {
|
||||
// Get bundled file
|
||||
const file = bundledFileMap.get(filePath);
|
||||
if (file) {
|
||||
return new Response(file.data, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': embeddedFile.contentType },
|
||||
headers: { 'Content-Type': file.contentType },
|
||||
});
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for unknown paths
|
||||
const indexFile = getEmbeddedFile('/index.html');
|
||||
const indexFile = bundledFileMap.get('/index.html');
|
||||
if (indexFile) {
|
||||
return new Response(indexFile.data as unknown as BodyInit, {
|
||||
return new Response(indexFile.data, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
@@ -312,17 +344,34 @@ export class StackGalleryRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API requests
|
||||
* Handle TypedRequest calls
|
||||
*/
|
||||
private async handleApiRequest(request: Request): Promise<Response> {
|
||||
if (!this.apiRouter) {
|
||||
return new Response(JSON.stringify({ error: 'API router not initialized' }), {
|
||||
private async handleTypedRequest(request: Request): Promise<Response> {
|
||||
if (!this.opsServer) {
|
||||
return new Response(JSON.stringify({ error: 'OpsServer not initialized' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return await this.apiRouter.handle(request);
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await this.opsServer.typedrouter.routeAndAddResponse(body);
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[StackGalleryRegistry] TypedRequest error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,6 +401,9 @@ export class StackGalleryRegistry {
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
console.log('[StackGalleryRegistry] Shutting down...');
|
||||
if (this.opsServer) {
|
||||
await this.opsServer.stop();
|
||||
}
|
||||
await closeDb();
|
||||
this.isInitialized = false;
|
||||
console.log('[StackGalleryRegistry] Shutdown complete');
|
||||
@@ -420,9 +472,10 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
|
||||
const s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`;
|
||||
|
||||
const config: IRegistryConfig = {
|
||||
mongoUrl:
|
||||
env.MONGODB_URL ||
|
||||
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
|
||||
mongoUrl: env.MONGODB_URL ||
|
||||
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${
|
||||
env.MONGODB_PORT || '27017'
|
||||
}/${env.MONGODB_NAME}?authSource=admin`,
|
||||
mongoDb: env.MONGODB_NAME || 'stackgallery',
|
||||
s3Endpoint: s3Endpoint,
|
||||
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
|
||||
@@ -444,7 +497,7 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
|
||||
} else {
|
||||
console.warn(
|
||||
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
|
||||
error
|
||||
error,
|
||||
);
|
||||
}
|
||||
return createRegistryFromEnv();
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* WebSocket manager for hot reload
|
||||
* Generates a unique instance ID on startup and broadcasts it to connected clients.
|
||||
* When the server restarts, clients detect the new ID and reload the page.
|
||||
*/
|
||||
export class ReloadSocketManager {
|
||||
private instanceId: string;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
|
||||
constructor() {
|
||||
this.instanceId = crypto.randomUUID();
|
||||
console.log(`[ReloadSocket] Instance ID: ${this.instanceId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current instance ID
|
||||
*/
|
||||
getInstanceId(): string {
|
||||
return this.instanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket upgrade request
|
||||
*/
|
||||
handleUpgrade(request: Request): Response {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
|
||||
socket.onopen = () => {
|
||||
this.clients.add(socket);
|
||||
console.log(`[ReloadSocket] Client connected (${this.clients.size} total)`);
|
||||
// Send instance ID immediately
|
||||
socket.send(JSON.stringify({ type: 'instance', id: this.instanceId }));
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
this.clients.delete(socket);
|
||||
console.log(`[ReloadSocket] Client disconnected (${this.clients.size} remaining)`);
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error('[ReloadSocket] WebSocket error:', error);
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a message to all connected clients
|
||||
*/
|
||||
broadcast(message: object): void {
|
||||
const msg = JSON.stringify(message);
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of connected clients
|
||||
*/
|
||||
getClientCount(): number {
|
||||
return this.clients.size;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export class AuditService {
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
} = {}
|
||||
} = {},
|
||||
): Promise<AuditLog> {
|
||||
return await AuditLog.log({
|
||||
actorId: this.context.actorId,
|
||||
@@ -75,7 +75,7 @@ export class AuditService {
|
||||
resourceType: TAuditResourceType,
|
||||
resourceId?: string,
|
||||
resourceName?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
@@ -94,7 +94,7 @@ export class AuditService {
|
||||
errorCode: string,
|
||||
errorMessage: string,
|
||||
resourceId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
@@ -107,11 +107,21 @@ export class AuditService {
|
||||
|
||||
// Convenience methods for common actions
|
||||
|
||||
public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise<AuditLog> {
|
||||
public async logUserLogin(
|
||||
userId: string,
|
||||
success: boolean,
|
||||
errorMessage?: string,
|
||||
): Promise<AuditLog> {
|
||||
if (success) {
|
||||
return await this.logSuccess('AUTH_LOGIN', 'user', userId);
|
||||
}
|
||||
return await this.logFailure('AUTH_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
|
||||
return await this.logFailure(
|
||||
'AUTH_LOGIN',
|
||||
'user',
|
||||
'LOGIN_FAILED',
|
||||
errorMessage || 'Login failed',
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
public async logUserLogout(userId: string): Promise<AuditLog> {
|
||||
@@ -131,7 +141,7 @@ export class AuditService {
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PUSHED', 'package', {
|
||||
resourceId: packageId,
|
||||
@@ -148,7 +158,7 @@ export class AuditService {
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PULLED', 'package', {
|
||||
resourceId: packageId,
|
||||
@@ -167,7 +177,7 @@ export class AuditService {
|
||||
public async logRepositoryCreated(
|
||||
repoId: string,
|
||||
repoName: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('REPO_CREATED', 'repository', {
|
||||
resourceId: repoId,
|
||||
@@ -182,7 +192,7 @@ export class AuditService {
|
||||
resourceId: string,
|
||||
targetUserId: string,
|
||||
oldRole: string | null,
|
||||
newRole: string | null
|
||||
newRole: string | null,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('ORG_MEMBER_ROLE_CHANGED', resourceType, {
|
||||
resourceId,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { User, Session } from '../models/index.ts';
|
||||
import { Session, User } from '../models/index.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface IJwtPayload {
|
||||
@@ -52,7 +52,7 @@ export class AuthService {
|
||||
public async login(
|
||||
email: string,
|
||||
password: string,
|
||||
options: { userAgent?: string; ipAddress?: string } = {}
|
||||
options: { userAgent?: string; ipAddress?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
const auditContext = AuditService.withContext({
|
||||
actorIp: options.ipAddress,
|
||||
@@ -195,7 +195,7 @@ export class AuthService {
|
||||
*/
|
||||
public async logout(
|
||||
sessionId: string,
|
||||
options: { userId?: string; ipAddress?: string } = {}
|
||||
options: { userId?: string; ipAddress?: string } = {},
|
||||
): Promise<boolean> {
|
||||
const session = await Session.findValidSession(sessionId);
|
||||
if (!session) return false;
|
||||
@@ -218,7 +218,7 @@ export class AuthService {
|
||||
*/
|
||||
public async logoutAll(
|
||||
userId: string,
|
||||
options: { ipAddress?: string } = {}
|
||||
options: { ipAddress?: string } = {},
|
||||
): Promise<number> {
|
||||
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
|
||||
|
||||
@@ -238,7 +238,9 @@ export class AuthService {
|
||||
/**
|
||||
* Validate access token and return user
|
||||
*/
|
||||
public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> {
|
||||
public async validateAccessToken(
|
||||
accessToken: string,
|
||||
): Promise<{ user: User; sessionId: string } | null> {
|
||||
const payload = await this.verifyToken(accessToken);
|
||||
if (!payload || payload.type !== 'access') return null;
|
||||
|
||||
@@ -339,7 +341,7 @@ export class AuthService {
|
||||
encoder.encode(this.config.jwtSecret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthCallbackData {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||
|
||||
@@ -23,7 +23,7 @@ interface ILdapEntry {
|
||||
export class LdapStrategy implements IAuthStrategy {
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
private cryptoService: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
*/
|
||||
public async authenticateCredentials(
|
||||
username: string,
|
||||
password: string
|
||||
password: string,
|
||||
): Promise<IExternalUserInfo> {
|
||||
const config = this.provider.ldapConfig;
|
||||
if (!config) {
|
||||
@@ -55,7 +55,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
bindPassword,
|
||||
config.baseDn,
|
||||
userFilter,
|
||||
password
|
||||
password,
|
||||
);
|
||||
|
||||
// Map LDAP attributes to user info
|
||||
@@ -86,7 +86,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn
|
||||
config.baseDn,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -129,7 +129,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
bindPassword: string,
|
||||
baseDn: string,
|
||||
userFilter: string,
|
||||
userPassword: string
|
||||
userPassword: string,
|
||||
): Promise<ILdapEntry> {
|
||||
// In a real implementation, this would:
|
||||
// 1. Connect to LDAP server
|
||||
@@ -150,7 +150,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
|
||||
throw new Error(
|
||||
'LDAP authentication is not yet fully implemented. ' +
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).'
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string
|
||||
baseDn: string,
|
||||
): Promise<void> {
|
||||
// Similar to ldapBind, this is a placeholder
|
||||
// Would connect and bind with service account to verify connectivity
|
||||
@@ -185,7 +185,9 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
|
||||
// Return success for configuration validation
|
||||
// Actual connectivity test would happen with LDAP library
|
||||
console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)');
|
||||
console.log(
|
||||
'[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,15 +208,9 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: entry[mapping.username]
|
||||
? String(entry[mapping.username])
|
||||
: undefined,
|
||||
displayName: entry[mapping.displayName]
|
||||
? String(entry[mapping.displayName])
|
||||
: undefined,
|
||||
groups: mapping.groups
|
||||
? this.parseGroups(entry[mapping.groups])
|
||||
: undefined,
|
||||
username: entry[mapping.username] ? String(entry[mapping.username]) : undefined,
|
||||
displayName: entry[mapping.displayName] ? String(entry[mapping.displayName]) : undefined,
|
||||
groups: mapping.groups ? this.parseGroups(entry[mapping.groups]) : undefined,
|
||||
rawAttributes: entry as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||
|
||||
@@ -34,7 +34,7 @@ export class OAuthStrategy implements IAuthStrategy {
|
||||
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
private cryptoService: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -243,19 +243,15 @@ export class OAuthStrategy implements IAuthStrategy {
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: rawInfo[mapping.username]
|
||||
? String(rawInfo[mapping.username])
|
||||
: undefined,
|
||||
displayName: rawInfo[mapping.displayName]
|
||||
? String(rawInfo[mapping.displayName])
|
||||
: undefined,
|
||||
username: rawInfo[mapping.username] ? String(rawInfo[mapping.username]) : undefined,
|
||||
displayName: rawInfo[mapping.displayName] ? String(rawInfo[mapping.displayName]) : undefined,
|
||||
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
|
||||
? String(rawInfo[mapping.avatarUrl])
|
||||
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
|
||||
groups: mapping.groups && rawInfo[mapping.groups]
|
||||
? (Array.isArray(rawInfo[mapping.groups])
|
||||
? (rawInfo[mapping.groups] as string[])
|
||||
: [String(rawInfo[mapping.groups])])
|
||||
? (rawInfo[mapping.groups] as string[])
|
||||
: [String(rawInfo[mapping.groups])])
|
||||
: undefined,
|
||||
rawAttributes: rawInfo,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export class CryptoService {
|
||||
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
|
||||
if (!keyHex) {
|
||||
console.warn(
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)',
|
||||
);
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
|
||||
@@ -52,7 +52,7 @@ export class CryptoService {
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||
this.masterKey,
|
||||
encoded.buffer as ArrayBuffer
|
||||
encoded.buffer as ArrayBuffer,
|
||||
);
|
||||
|
||||
// Format: iv:ciphertext (both base64)
|
||||
@@ -88,7 +88,7 @@ export class CryptoService {
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||
this.masterKey,
|
||||
encrypted.buffer as ArrayBuffer
|
||||
encrypted.buffer as ArrayBuffer,
|
||||
);
|
||||
|
||||
// Decode to string
|
||||
@@ -123,7 +123,7 @@ export class CryptoService {
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||||
import {
|
||||
AuthProvider,
|
||||
ExternalIdentity,
|
||||
PlatformSettings,
|
||||
Session,
|
||||
User,
|
||||
} from '../models/index.ts';
|
||||
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
import { cryptoService } from './crypto.service.ts';
|
||||
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||||
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||||
import type { IConnectionTestResult, IExternalUserInfo } from '../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthState {
|
||||
providerId: string;
|
||||
@@ -33,7 +39,7 @@ export class ExternalAuthService {
|
||||
*/
|
||||
public async initiateOAuth(
|
||||
providerId: string,
|
||||
returnUrl?: string
|
||||
returnUrl?: string,
|
||||
): Promise<{ authUrl: string; state: string }> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
@@ -67,7 +73,7 @@ export class ExternalAuthService {
|
||||
*/
|
||||
public async handleOAuthCallback(
|
||||
data: IOAuthCallbackData,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
options: { ipAddress?: string; userAgent?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
// Validate state
|
||||
const stateData = await this.validateState(data.state);
|
||||
@@ -170,7 +176,7 @@ export class ExternalAuthService {
|
||||
providerId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
options: { ipAddress?: string; userAgent?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||
@@ -261,7 +267,7 @@ export class ExternalAuthService {
|
||||
public async linkProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
externalUser: IExternalUserInfo
|
||||
externalUser: IExternalUserInfo,
|
||||
): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked to another user
|
||||
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||
@@ -377,12 +383,12 @@ export class ExternalAuthService {
|
||||
private async findOrCreateUser(
|
||||
provider: AuthProvider,
|
||||
externalUser: IExternalUserInfo,
|
||||
options: { ipAddress?: string } = {}
|
||||
options: { ipAddress?: string } = {},
|
||||
): Promise<{ user: User; isNew: boolean }> {
|
||||
// 1. Check if external identity already exists
|
||||
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||
provider.id,
|
||||
externalUser.externalId
|
||||
externalUser.externalId,
|
||||
);
|
||||
|
||||
if (existingIdentity) {
|
||||
@@ -544,12 +550,12 @@ export class ExternalAuthService {
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const encodedSignature = this.base64UrlEncode(
|
||||
String.fromCharCode(...new Uint8Array(signature))
|
||||
String.fromCharCode(...new Uint8Array(signature)),
|
||||
);
|
||||
|
||||
return `${data}.${encodedSignature}`;
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
|
||||
export { AuditService, type IAuditContext } from './audit.service.ts';
|
||||
export {
|
||||
TokenService,
|
||||
type ICreateTokenOptions,
|
||||
type ITokenValidationResult,
|
||||
TokenService,
|
||||
} from './token.service.ts';
|
||||
export {
|
||||
PermissionService,
|
||||
type TAction,
|
||||
type IPermissionContext,
|
||||
type IResolvedPermissions,
|
||||
PermissionService,
|
||||
type TAction,
|
||||
} from './permission.service.ts';
|
||||
export {
|
||||
AuthService,
|
||||
type IJwtPayload,
|
||||
type IAuthResult,
|
||||
type IAuthConfig,
|
||||
type IAuthResult,
|
||||
type IJwtPayload,
|
||||
} from './auth.service.ts';
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
|
||||
import type {
|
||||
TOrganizationRole,
|
||||
TTeamRole,
|
||||
TRepositoryRole,
|
||||
TRegistryProtocol,
|
||||
TRepositoryRole,
|
||||
TTeamRole,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import {
|
||||
User,
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
Team,
|
||||
TeamMember,
|
||||
Repository,
|
||||
RepositoryPermission,
|
||||
Team,
|
||||
TeamMember,
|
||||
User,
|
||||
} from '../models/index.ts';
|
||||
|
||||
export type TAction = 'read' | 'write' | 'delete' | 'admin';
|
||||
@@ -71,7 +71,10 @@ export class PermissionService {
|
||||
if (!context.organizationId) return result;
|
||||
|
||||
// Get organization membership
|
||||
const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId);
|
||||
const orgMember = await OrganizationMember.findMembership(
|
||||
context.organizationId,
|
||||
context.userId,
|
||||
);
|
||||
if (orgMember) {
|
||||
result.organizationRole = orgMember.role;
|
||||
|
||||
@@ -137,7 +140,10 @@ export class PermissionService {
|
||||
}
|
||||
|
||||
// Get direct repository permission (highest priority)
|
||||
const repoPerm = await RepositoryPermission.findPermission(context.repositoryId, context.userId);
|
||||
const repoPerm = await RepositoryPermission.findPermission(
|
||||
context.repositoryId,
|
||||
context.userId,
|
||||
);
|
||||
if (repoPerm) {
|
||||
result.repositoryRole = repoPerm.role;
|
||||
this.applyRole(result, repoPerm.role);
|
||||
@@ -151,7 +157,7 @@ export class PermissionService {
|
||||
*/
|
||||
public async checkPermission(
|
||||
context: IPermissionContext,
|
||||
action: TAction
|
||||
action: TAction,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions(context);
|
||||
|
||||
@@ -176,11 +182,11 @@ export class PermissionService {
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string,
|
||||
action: 'read' | 'write' | 'delete'
|
||||
action: 'read' | 'write' | 'delete',
|
||||
): Promise<boolean> {
|
||||
return await this.checkPermission(
|
||||
{ userId, organizationId, repositoryId },
|
||||
action
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,7 +208,7 @@ export class PermissionService {
|
||||
public async canManageRepository(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions({
|
||||
userId,
|
||||
@@ -217,7 +223,7 @@ export class PermissionService {
|
||||
*/
|
||||
public async getAccessibleRepositories(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<Repository[]> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return [];
|
||||
|
||||
@@ -37,7 +37,9 @@ export class TokenService {
|
||||
* Generate a new API token
|
||||
* Returns the raw token (only shown once) and the saved token record
|
||||
*/
|
||||
public async createToken(options: ICreateTokenOptions): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
public async createToken(
|
||||
options: ICreateTokenOptions,
|
||||
): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
// Generate secure random token: srg_{64 hex chars}
|
||||
const randomBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
@@ -206,7 +208,7 @@ export class TokenService {
|
||||
protocol: TRegistryProtocol,
|
||||
organizationId?: string,
|
||||
repositoryId?: string,
|
||||
action?: string
|
||||
action?: string,
|
||||
): boolean {
|
||||
if (!token.hasProtocol(protocol)) return false;
|
||||
return token.hasScope(protocol, organizationId, repositoryId, action);
|
||||
|
||||
Reference in New Issue
Block a user