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:
2026-03-20 16:43:44 +00:00
parent 0fc74ff995
commit d4f758ce0f
159 changed files with 12465 additions and 14861 deletions

View File

@@ -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'
}

View File

@@ -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,
);
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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' } };
}

View File

@@ -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) {

View File

@@ -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' } };

View File

@@ -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;

View File

@@ -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),
);
}
/**

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 = '';

View File

@@ -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 = '';

View File

@@ -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;

View File

@@ -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 });
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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',
);
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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({

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 = '';

View 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');
}
}

View 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');
}
},
),
);
}
}

View 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');
}
},
),
);
}
}

View 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' },
);
}

View 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';

View 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');
}
},
),
);
}
}

View 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');
}
},
),
);
}
}

View 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');
}
},
),
);
}
}

View 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');
}
},
),
);
}
}

View 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');
}
},
),
);
}
}

View 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');
}
},
),
);
}
}

View 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');
}
}

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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';

View File

@@ -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({

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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));

View File

@@ -4,8 +4,8 @@
*/
import type {
IExternalUserInfo,
IConnectionTestResult,
IExternalUserInfo,
} from '../../../interfaces/auth.interfaces.ts';
export interface IOAuthCallbackData {

View File

@@ -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>,
};
}

View File

@@ -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,
};

View File

@@ -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'],
);
}

View File

@@ -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}`;

View File

@@ -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';

View File

@@ -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 [];

View File

@@ -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);