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:
2025-11-27 22:15:38 +00:00
parent a6c6ea1393
commit ab88ac896f
71 changed files with 9446 additions and 0 deletions

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

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

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

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

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