/** * 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; actor?: IStackGalleryActor; ip?: string; userAgent?: string; } export interface IApiResponse { status: number; body?: unknown; headers?: Record; } type RouteHandler = (ctx: IApiContext) => Promise; 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 { 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 = {}; 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 { 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 = { 'Content-Type': 'application/json', ...result.headers, }; return new Response(result.body ? JSON.stringify(result.body) : null, { status: result.status, headers, }); } }