feat: implement account settings and API tokens management
- Added SettingsComponent for user profile management, including display name and password change functionality. - Introduced TokensComponent for managing API tokens, including creation and revocation. - Created LayoutComponent for consistent application layout with navigation and user information. - Established main application structure in index.html and main.ts. - Integrated Tailwind CSS for styling and responsive design. - Configured TypeScript settings for strict type checking and module resolution.
This commit is contained in:
109
ts/api/handlers/audit.api.ts
Normal file
109
ts/api/handlers/audit.api.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Audit API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditLog } from '../../models/auditlog.ts';
|
||||
import type { TAuditAction, TAuditResourceType } from '../../interfaces/audit.interfaces.ts';
|
||||
|
||||
export class AuditApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit
|
||||
*/
|
||||
public async query(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
// 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 actionsParam = ctx.url.searchParams.get('actions');
|
||||
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
|
||||
const success = ctx.url.searchParams.has('success')
|
||||
? ctx.url.searchParams.get('success') === 'true'
|
||||
: undefined;
|
||||
const startDateParam = ctx.url.searchParams.get('startDate');
|
||||
const endDateParam = ctx.url.searchParams.get('endDate');
|
||||
const startDate = startDateParam ? new Date(startDateParam) : undefined;
|
||||
const endDate = endDateParam ? new Date(endDateParam) : undefined;
|
||||
const limit = parseInt(ctx.url.searchParams.get('limit') || '100', 10);
|
||||
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
// Check permissions
|
||||
// Users can view audit logs for:
|
||||
// 1. Their own actions (actorId = userId)
|
||||
// 2. Organizations they manage
|
||||
// 3. System admins can view all
|
||||
|
||||
let actorId: string | undefined;
|
||||
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
// System admins can see all
|
||||
actorId = ctx.url.searchParams.get('actorId') || undefined;
|
||||
} else if (organizationId) {
|
||||
// Check if user can manage this org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId
|
||||
);
|
||||
if (!canManage) {
|
||||
// User can only see their own actions in this org
|
||||
actorId = ctx.actor.userId;
|
||||
}
|
||||
} else {
|
||||
// Non-admins without org filter can only see their own actions
|
||||
actorId = ctx.actor.userId;
|
||||
}
|
||||
|
||||
const result = await AuditLog.query({
|
||||
actorId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
resourceType,
|
||||
action: actions,
|
||||
success,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
logs: result.logs.map((log) => ({
|
||||
id: log.id,
|
||||
actorId: log.actorId,
|
||||
actorType: log.actorType,
|
||||
action: log.action,
|
||||
resourceType: log.resourceType,
|
||||
resourceId: log.resourceId,
|
||||
resourceName: log.resourceName,
|
||||
organizationId: log.organizationId,
|
||||
repositoryId: log.repositoryId,
|
||||
success: log.success,
|
||||
errorCode: log.errorCode,
|
||||
timestamp: log.timestamp,
|
||||
metadata: log.metadata,
|
||||
})),
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuditApi] Query error:', error);
|
||||
return { status: 500, body: { error: 'Failed to query audit logs' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
184
ts/api/handlers/auth.api.ts
Normal file
184
ts/api/handlers/auth.api.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Auth API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
|
||||
export class AuthApi {
|
||||
private authService: AuthService;
|
||||
|
||||
constructor(authService: AuthService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/login
|
||||
*/
|
||||
public async login(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Email and password are required' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.authService.login(email, password, {
|
||||
userAgent: ctx.userAgent,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: result.errorMessage,
|
||||
code: result.errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
user: {
|
||||
id: result.user!.id,
|
||||
email: result.user!.email,
|
||||
username: result.user!.username,
|
||||
displayName: result.user!.displayName,
|
||||
isSystemAdmin: result.user!.isSystemAdmin,
|
||||
},
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionId: result.sessionId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Login error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Login failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/refresh
|
||||
*/
|
||||
public async refresh(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { refreshToken } = body;
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Refresh token is required' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.authService.refresh(refreshToken);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: result.errorMessage,
|
||||
code: result.errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Refresh error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Token refresh failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/logout
|
||||
*/
|
||||
public async logout(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return {
|
||||
status: 401,
|
||||
body: { error: 'Authentication required' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json().catch(() => ({}));
|
||||
const { sessionId, all } = body;
|
||||
|
||||
if (all) {
|
||||
const count = await this.authService.logoutAll(ctx.actor.userId, {
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: `Logged out from ${count} sessions` },
|
||||
};
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
await this.authService.logout(sessionId, {
|
||||
userId: ctx.actor.userId,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Logged out successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Logout error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Logout failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/me
|
||||
*/
|
||||
public async me(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId || !ctx.actor.user) {
|
||||
return {
|
||||
status: 401,
|
||||
body: { error: 'Authentication required' },
|
||||
};
|
||||
}
|
||||
|
||||
const user = ctx.actor.user;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
11
ts/api/handlers/index.ts
Normal file
11
ts/api/handlers/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* API handler exports
|
||||
*/
|
||||
|
||||
export { AuthApi } from './auth.api.ts';
|
||||
export { UserApi } from './user.api.ts';
|
||||
export { OrganizationApi } from './organization.api.ts';
|
||||
export { RepositoryApi } from './repository.api.ts';
|
||||
export { PackageApi } from './package.api.ts';
|
||||
export { TokenApi } from './token.api.ts';
|
||||
export { AuditApi } from './audit.api.ts';
|
||||
494
ts/api/handlers/organization.api.ts
Normal file
494
ts/api/handlers/organization.api.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Organization API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import { Organization, OrganizationMember, User } from '../../models/index.ts';
|
||||
import type { TOrganizationRole } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class OrganizationApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
// System admins see all orgs, others see only their orgs
|
||||
let organizations: Organization[];
|
||||
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
organizations = await Organization.getInstances({});
|
||||
} else {
|
||||
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
organizations: organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
createdAt: org.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list organizations' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check access - public orgs are visible to all authenticated users
|
||||
if (!org.isPublic && ctx.actor?.userId) {
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
settings: ctx.actor?.user?.isSystemAdmin ? org.settings : undefined,
|
||||
usedStorageBytes: org.usedStorageBytes,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, displayName, description, isPublic } = body;
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Organization name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if name is taken
|
||||
const existing = await Organization.findByName(name);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: '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 = ctx.actor.userId;
|
||||
|
||||
await org.save();
|
||||
|
||||
// Add creator as owner
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = ctx.actor.userId;
|
||||
membership.role = 'owner';
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).logOrganizationCreated(org.id, org.name);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
isPublic: org.isPublic,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
|
||||
|
||||
if (displayName !== undefined) org.displayName = displayName;
|
||||
if (description !== undefined) org.description = description;
|
||||
if (avatarUrl !== undefined) org.avatarUrl = avatarUrl;
|
||||
if (website !== undefined) org.website = website;
|
||||
if (isPublic !== undefined) org.isPublic = isPublic;
|
||||
|
||||
// Only system admins can change settings
|
||||
if (settings && ctx.actor.user?.isSystemAdmin) {
|
||||
org.settings = { ...org.settings, ...settings };
|
||||
}
|
||||
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// TODO: Check for packages, repositories before deletion
|
||||
// For now, just delete the organization and memberships
|
||||
await org.delete();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Organization deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id/members
|
||||
*/
|
||||
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await OrganizationMember.getOrgMembers(id);
|
||||
|
||||
// Fetch user details
|
||||
const membersWithUsers = await Promise.all(
|
||||
members.map(async (m) => {
|
||||
const user = await User.findById(m.userId);
|
||||
return {
|
||||
userId: m.userId,
|
||||
role: m.role,
|
||||
addedAt: m.addedAt,
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { members: membersWithUsers },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] List members error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list members' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:id/members
|
||||
*/
|
||||
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { userId, role } = body as { userId: string; role: TOrganizationRole };
|
||||
|
||||
if (!userId || !role) {
|
||||
return { status: 400, body: { error: 'userId and role are required' } };
|
||||
}
|
||||
|
||||
if (!['owner', 'admin', 'member'].includes(role)) {
|
||||
return { status: 400, body: { error: 'Invalid role' } };
|
||||
}
|
||||
|
||||
// Check user exists
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existing = await OrganizationMember.findMembership(id, userId);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'User is already a member' } };
|
||||
}
|
||||
|
||||
// Add member
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
userId: membership.userId,
|
||||
role: membership.role,
|
||||
addedAt: membership.addedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Add member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to add member' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id/members/:userId
|
||||
*/
|
||||
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { role } = body as { role: TOrganizationRole };
|
||||
|
||||
if (!role || !['owner', 'admin', 'member'].includes(role)) {
|
||||
return { status: 400, body: { error: 'Valid role is required' } };
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot change last owner
|
||||
if (membership.role === 'owner' && role !== 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
}
|
||||
}
|
||||
|
||||
membership.role = role;
|
||||
await membership.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
userId: membership.userId,
|
||||
role: membership.role,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Update member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update member' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id/members/:userId
|
||||
*/
|
||||
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot remove last owner
|
||||
if (membership.role === 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
}
|
||||
}
|
||||
|
||||
await membership.delete();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Member removed successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Remove member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to remove member' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
321
ts/api/handlers/package.api.ts
Normal file
321
ts/api/handlers/package.api.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Package API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { Package, Repository } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class PackageApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages (search)
|
||||
*/
|
||||
public async search(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const query = ctx.url.searchParams.get('q') || '';
|
||||
const protocol = ctx.url.searchParams.get('protocol') as TRegistryProtocol | undefined;
|
||||
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
||||
const limit = parseInt(ctx.url.searchParams.get('limit') || '50', 10);
|
||||
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
// For authenticated users, search includes private packages they have access to
|
||||
// For anonymous users, only search public packages
|
||||
const isPrivate = ctx.actor?.userId ? undefined : false;
|
||||
|
||||
const packages = await Package.search(query, {
|
||||
protocol,
|
||||
organizationId,
|
||||
isPrivate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
// Filter out packages user doesn't have access to
|
||||
const accessiblePackages = [];
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.isPrivate) {
|
||||
accessiblePackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ctx.actor?.userId) {
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
if (canAccess) {
|
||||
accessiblePackages.push(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
packages: accessiblePackages.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
latestVersion: pkg.distTags['latest'],
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount,
|
||||
updatedAt: pkg.updatedAt,
|
||||
})),
|
||||
total: accessiblePackages.length,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Search error:', error);
|
||||
return { status: 500, body: { error: 'Failed to search packages' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (pkg.isPrivate) {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
distTags: pkg.distTags,
|
||||
versions: Object.keys(pkg.versions),
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount,
|
||||
starCount: pkg.starCount,
|
||||
storageBytes: pkg.storageBytes,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get package' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages/:id/versions
|
||||
*/
|
||||
public async listVersions(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (pkg.isPrivate) {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
const versions = Object.entries(pkg.versions).map(([version, data]) => ({
|
||||
version,
|
||||
publishedAt: data.publishedAt,
|
||||
size: data.size,
|
||||
downloads: data.downloads,
|
||||
checksum: data.checksum,
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
packageId: pkg.id,
|
||||
packageName: pkg.name,
|
||||
distTags: pkg.distTags,
|
||||
versions,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] List versions error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list versions' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/packages/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
return { status: 403, body: { error: 'Delete permission required' } };
|
||||
}
|
||||
|
||||
// Delete the package
|
||||
await pkg.delete();
|
||||
|
||||
// Update repository package count
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.packageCount = Math.max(0, repo.packageCount - 1);
|
||||
repo.storageBytes -= pkg.storageBytes;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Package deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete package' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/packages/:id/versions/:version
|
||||
*/
|
||||
public async deleteVersion(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, version } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
const versionData = pkg.versions[version];
|
||||
if (!versionData) {
|
||||
return { status: 404, body: { error: 'Version not found' } };
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
return { status: 403, body: { error: 'Delete permission required' } };
|
||||
}
|
||||
|
||||
// Check if this is the only version
|
||||
if (Object.keys(pkg.versions).length === 1) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Cannot delete the only version. Delete the entire package instead.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Remove version
|
||||
const sizeReduction = versionData.size;
|
||||
delete pkg.versions[version];
|
||||
pkg.storageBytes -= sizeReduction;
|
||||
|
||||
// Update dist tags
|
||||
for (const [tag, tagVersion] of Object.entries(pkg.distTags)) {
|
||||
if (tagVersion === 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 {
|
||||
status: 200,
|
||||
body: { message: 'Version deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Delete version error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete version' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
293
ts/api/handlers/repository.api.ts
Normal file
293
ts/api/handlers/repository.api.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Repository API handlers
|
||||
*/
|
||||
|
||||
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 type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class RepositoryApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:orgId/repositories
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { orgId } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get accessible repositories
|
||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||
ctx.actor.userId,
|
||||
orgId
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
repositories: repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
createdAt: repo.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list repositories' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/repositories/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (!repo.isPublic && ctx.actor?.userId) {
|
||||
const permissions = await this.permissionService.resolvePermissions({
|
||||
userId: ctx.actor.userId,
|
||||
organizationId: repo.organizationId,
|
||||
repositoryId: repo.id,
|
||||
});
|
||||
|
||||
if (!permissions.canRead) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes,
|
||||
createdAt: repo.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:orgId/repositories
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { orgId } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, orgId);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, displayName, description, protocols, isPublic, settings } = body;
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Repository name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check org exists
|
||||
const org = await Organization.findById(orgId);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check if name is taken in this org
|
||||
const existing = await Repository.findByName(orgId, name);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Repository name already taken in this organization' } };
|
||||
}
|
||||
|
||||
// Create repository
|
||||
const repo = new Repository();
|
||||
repo.id = await Repository.getNewId();
|
||||
repo.organizationId = orgId;
|
||||
repo.name = name;
|
||||
repo.displayName = displayName || name;
|
||||
repo.description = description;
|
||||
repo.protocols = protocols || ['npm'];
|
||||
repo.isPublic = isPublic ?? false;
|
||||
repo.settings = settings || {
|
||||
allowOverwrite: false,
|
||||
immutableTags: false,
|
||||
retentionDays: 0,
|
||||
};
|
||||
repo.createdAt = new Date();
|
||||
repo.createdById = ctx.actor.userId;
|
||||
|
||||
await repo.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
organizationId: orgId,
|
||||
}).logRepositoryCreated(repo.id, repo.name, orgId);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
createdAt: repo.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/repositories/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, protocols, isPublic, settings } = body;
|
||||
|
||||
if (displayName !== undefined) repo.displayName = displayName;
|
||||
if (description !== undefined) repo.description = description;
|
||||
if (protocols !== undefined) repo.protocols = protocols;
|
||||
if (isPublic !== undefined) repo.isPublic = isPublic;
|
||||
if (settings !== undefined) repo.settings = { ...repo.settings, ...settings };
|
||||
|
||||
await repo.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/repositories/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
// Check for packages
|
||||
if (repo.packageCount > 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Cannot delete repository with packages. Remove all packages first.' },
|
||||
};
|
||||
}
|
||||
|
||||
await repo.delete();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Repository deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete repository' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
157
ts/api/handlers/token.api.ts
Normal file
157
ts/api/handlers/token.api.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Token API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { TokenService } from '../../services/token.service.ts';
|
||||
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class TokenApi {
|
||||
private tokenService: TokenService;
|
||||
|
||||
constructor(tokenService: TokenService) {
|
||||
this.tokenService = tokenService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/tokens
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
tokens: tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
protocols: t.protocols,
|
||||
scopes: t.scopes,
|
||||
expiresAt: t.expiresAt,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
usageCount: t.usageCount,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list tokens' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/tokens
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, protocols, scopes, expiresInDays } = body as {
|
||||
name: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresInDays?: number;
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Token name is required' } };
|
||||
}
|
||||
|
||||
if (!protocols || protocols.length === 0) {
|
||||
return { status: 400, body: { error: 'At least one protocol is required' } };
|
||||
}
|
||||
|
||||
if (!scopes || scopes.length === 0) {
|
||||
return { status: 400, body: { error: '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)) {
|
||||
return { status: 400, body: { error: `Invalid protocol: ${protocol}` } };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
for (const scope of scopes) {
|
||||
if (!scope.protocol || !scope.actions || scope.actions.length === 0) {
|
||||
return { status: 400, body: { error: 'Invalid scope configuration' } };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.tokenService.createToken({
|
||||
userId: ctx.actor.userId,
|
||||
name,
|
||||
protocols,
|
||||
scopes,
|
||||
expiresInDays,
|
||||
createdIp: ctx.ip,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: result.token.id,
|
||||
name: result.token.name,
|
||||
token: result.rawToken, // Only returned once!
|
||||
tokenPrefix: result.token.tokenPrefix,
|
||||
protocols: result.token.protocols,
|
||||
scopes: result.token.scopes,
|
||||
expiresAt: result.token.expiresAt,
|
||||
createdAt: result.token.createdAt,
|
||||
warning: 'Store this token securely. It will not be shown again.',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create token' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/tokens/:id
|
||||
*/
|
||||
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get the token to verify ownership
|
||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
const token = tokens.find((t) => t.id === id);
|
||||
|
||||
if (!token) {
|
||||
// Either doesn't exist or doesn't belong to user
|
||||
return { status: 404, body: { error: 'Token not found' } };
|
||||
}
|
||||
|
||||
const success = await this.tokenService.revokeToken(id, 'user_revoked');
|
||||
|
||||
if (!success) {
|
||||
return { status: 500, body: { error: 'Failed to revoke token' } };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Token revoked successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] Revoke error:', error);
|
||||
return { status: 500, body: { error: 'Failed to revoke token' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
260
ts/api/handlers/user.api.ts
Normal file
260
ts/api/handlers/user.api.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* User API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
import { User } from '../../models/user.ts';
|
||||
|
||||
export class UserApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can list all users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await User.getInstances({});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
isSystemAdmin: u.isSystemAdmin,
|
||||
isActive: u.isActive,
|
||||
createdAt: u.createdAt,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list users' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Users can view their own profile, admins can view any
|
||||
if (id !== ctx.actor.userId && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can create users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { email, username, password, displayName, isSystemAdmin } = body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Email, username, and password are required' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await User.findByEmail(email);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Email already in use' } };
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await User.findByUsername(username);
|
||||
if (existingUsername) {
|
||||
return { status: 409, body: { error: 'Username already in use' } };
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await AuthService.hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = new User();
|
||||
user.id = await User.getNewId();
|
||||
user.email = email;
|
||||
user.username = username;
|
||||
user.passwordHash = passwordHash;
|
||||
user.displayName = displayName || username;
|
||||
user.isSystemAdmin = isSystemAdmin || false;
|
||||
user.isActive = true;
|
||||
user.createdAt = new Date();
|
||||
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/users/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Users can update their own profile, admins can update any
|
||||
if (id !== ctx.actor.userId && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, avatarUrl, password, isActive, isSystemAdmin } = body;
|
||||
|
||||
if (displayName !== undefined) user.displayName = displayName;
|
||||
if (avatarUrl !== undefined) user.avatarUrl = avatarUrl;
|
||||
|
||||
// Only admins can change these
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
if (isActive !== undefined) user.isActive = isActive;
|
||||
if (isSystemAdmin !== undefined) user.isSystemAdmin = isSystemAdmin;
|
||||
}
|
||||
|
||||
// Password change
|
||||
if (password) {
|
||||
user.passwordHash = await AuthService.hashPassword(password);
|
||||
}
|
||||
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/users/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can delete users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Cannot delete yourself
|
||||
if (id === ctx.actor.userId) {
|
||||
return { status: 400, body: { error: 'Cannot delete your own account' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
// Soft delete - deactivate instead of removing
|
||||
user.isActive = false;
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'User deactivated successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete user' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
6
ts/api/index.ts
Normal file
6
ts/api/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* API exports
|
||||
*/
|
||||
|
||||
export { ApiRouter, type IApiContext, type IApiResponse } from './router.ts';
|
||||
export * from './handlers/index.ts';
|
||||
277
ts/api/router.ts
Normal file
277
ts/api/router.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* API Router - Routes REST API requests to appropriate handlers
|
||||
*/
|
||||
|
||||
import type { IStackGalleryActor } from '../providers/auth.provider.ts';
|
||||
import { AuthService } from '../services/auth.service.ts';
|
||||
import { TokenService } from '../services/token.service.ts';
|
||||
import { PermissionService } from '../services/permission.service.ts';
|
||||
import { AuditService } from '../services/audit.service.ts';
|
||||
|
||||
// Import API handlers
|
||||
import { AuthApi } from './handlers/auth.api.ts';
|
||||
import { UserApi } from './handlers/user.api.ts';
|
||||
import { OrganizationApi } from './handlers/organization.api.ts';
|
||||
import { RepositoryApi } from './handlers/repository.api.ts';
|
||||
import { PackageApi } from './handlers/package.api.ts';
|
||||
import { TokenApi } from './handlers/token.api.ts';
|
||||
import { AuditApi } from './handlers/audit.api.ts';
|
||||
|
||||
export interface IApiContext {
|
||||
request: Request;
|
||||
url: URL;
|
||||
path: string;
|
||||
method: string;
|
||||
params: Record<string, string>;
|
||||
actor?: IStackGalleryActor;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface IApiResponse {
|
||||
status: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: IApiContext) => Promise<IApiResponse>;
|
||||
|
||||
interface IRoute {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
export class ApiRouter {
|
||||
private routes: IRoute[] = [];
|
||||
private authService: AuthService;
|
||||
private tokenService: TokenService;
|
||||
private permissionService: PermissionService;
|
||||
|
||||
// API handlers
|
||||
private authApi: AuthApi;
|
||||
private userApi: UserApi;
|
||||
private organizationApi: OrganizationApi;
|
||||
private repositoryApi: RepositoryApi;
|
||||
private packageApi: PackageApi;
|
||||
private tokenApi: TokenApi;
|
||||
private auditApi: AuditApi;
|
||||
|
||||
constructor() {
|
||||
this.authService = new AuthService();
|
||||
this.tokenService = new TokenService();
|
||||
this.permissionService = new PermissionService();
|
||||
|
||||
// Initialize API handlers
|
||||
this.authApi = new AuthApi(this.authService);
|
||||
this.userApi = new UserApi(this.permissionService);
|
||||
this.organizationApi = new OrganizationApi(this.permissionService);
|
||||
this.repositoryApi = new RepositoryApi(this.permissionService);
|
||||
this.packageApi = new PackageApi(this.permissionService);
|
||||
this.tokenApi = new TokenApi(this.tokenService);
|
||||
this.auditApi = new AuditApi(this.permissionService);
|
||||
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all API routes
|
||||
*/
|
||||
private registerRoutes(): void {
|
||||
// Auth routes
|
||||
this.addRoute('POST', '/api/v1/auth/login', (ctx) => this.authApi.login(ctx));
|
||||
this.addRoute('POST', '/api/v1/auth/refresh', (ctx) => this.authApi.refresh(ctx));
|
||||
this.addRoute('POST', '/api/v1/auth/logout', (ctx) => this.authApi.logout(ctx));
|
||||
this.addRoute('GET', '/api/v1/auth/me', (ctx) => this.authApi.me(ctx));
|
||||
|
||||
// User routes
|
||||
this.addRoute('GET', '/api/v1/users', (ctx) => this.userApi.list(ctx));
|
||||
this.addRoute('GET', '/api/v1/users/:id', (ctx) => this.userApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/users', (ctx) => this.userApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/users/:id', (ctx) => this.userApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/users/:id', (ctx) => this.userApi.delete(ctx));
|
||||
|
||||
// Organization routes
|
||||
this.addRoute('GET', '/api/v1/organizations', (ctx) => this.organizationApi.list(ctx));
|
||||
this.addRoute('GET', '/api/v1/organizations/:id', (ctx) => this.organizationApi.get(ctx));
|
||||
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));
|
||||
|
||||
// Repository routes
|
||||
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('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('DELETE', '/api/v1/packages/:id', (ctx) => this.packageApi.delete(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));
|
||||
this.addRoute('POST', '/api/v1/tokens', (ctx) => this.tokenApi.create(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/tokens/:id', (ctx) => this.tokenApi.revoke(ctx));
|
||||
|
||||
// Audit routes
|
||||
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a route
|
||||
*/
|
||||
private addRoute(method: string, path: string, handler: RouteHandler): void {
|
||||
const paramNames: string[] = [];
|
||||
const patternStr = path.replace(/:(\w+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
return '([^/]+)';
|
||||
});
|
||||
const pattern = new RegExp(`^${patternStr}$`);
|
||||
|
||||
this.routes.push({ method, pattern, paramNames, handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an API request
|
||||
*/
|
||||
public async handle(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract params
|
||||
const params: Record<string, string> = {};
|
||||
route.paramNames.forEach((name, i) => {
|
||||
params[name] = match[i + 1];
|
||||
});
|
||||
|
||||
// Build context
|
||||
const ctx: IApiContext = {
|
||||
request,
|
||||
url,
|
||||
path,
|
||||
method,
|
||||
params,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
};
|
||||
|
||||
// Authenticate request (except for login)
|
||||
if (!path.includes('/auth/login')) {
|
||||
ctx.actor = await this.authenticateRequest(request);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await route.handler(ctx);
|
||||
return this.buildResponse(result);
|
||||
} catch (error) {
|
||||
console.error('[ApiRouter] Handler error:', error);
|
||||
return this.buildResponse({
|
||||
status: 500,
|
||||
body: { error: 'Internal server error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildResponse({
|
||||
status: 404,
|
||||
body: { error: 'Not found' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate request from headers
|
||||
*/
|
||||
private async authenticateRequest(request: Request): Promise<IStackGalleryActor | undefined> {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
|
||||
// Try Bearer token (JWT)
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// Check if it's a JWT (for UI) or API token
|
||||
if (token.startsWith('srg_')) {
|
||||
// API token
|
||||
const result = await this.tokenService.validateToken(token, this.getClientIp(request));
|
||||
if (result.valid && result.token && result.user) {
|
||||
return {
|
||||
type: 'api_token',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
tokenId: result.token.id,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
protocols: result.token.protocols,
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// JWT token
|
||||
const result = await this.authService.validateAccessToken(token);
|
||||
if (result) {
|
||||
return {
|
||||
type: 'user',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP from request
|
||||
*/
|
||||
private getClientIp(request: Request): string {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTTP response from API response
|
||||
*/
|
||||
private buildResponse(result: IApiResponse): Response {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...result.headers,
|
||||
};
|
||||
|
||||
return new Response(result.body ? JSON.stringify(result.body) : null, {
|
||||
status: result.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user