diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..ea29b43 --- /dev/null +++ b/deno.json @@ -0,0 +1,46 @@ +{ + "name": "@stack.gallery/registry", + "version": "1.0.0", + "exports": "./mod.ts", + "tasks": { + "start": "deno run --allow-all mod.ts server", + "dev": "deno run --allow-all --watch mod.ts server --ephemeral", + "test": "deno test --allow-all", + "build": "cd ui && pnpm run build" + }, + "imports": { + "@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.5.0", + "@push.rocks/smartdata": "npm:@push.rocks/smartdata@^5.0.0", + "@push.rocks/smartbucket": "npm:@push.rocks/smartbucket@^4.3.0", + "@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.0.0", + "@push.rocks/smartenv": "npm:@push.rocks/smartenv@^5.0.0", + "@push.rocks/smartpath": "npm:@push.rocks/smartpath@^5.0.0", + "@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.0.0", + "@push.rocks/smartstring": "npm:@push.rocks/smartstring@^4.0.0", + "@push.rocks/smartcrypto": "npm:@push.rocks/smartcrypto@^2.0.0", + "@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.0.0", + "@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.0", + "@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.0", + "@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.0", + "@push.rocks/smartcli": "npm:@push.rocks/smartcli@^4.0.0", + "@tsclass/tsclass": "npm:@tsclass/tsclass@^9.0.0", + "@std/path": "jsr:@std/path@^1.0.0", + "@std/fs": "jsr:@std/fs@^1.0.0", + "@std/http": "jsr:@std/http@^1.0.0" + }, + "compilerOptions": { + "strict": true, + "lib": ["deno.window", "dom"], + "jsx": "react-jsx", + "jsxImportSource": "npm:react" + }, + "lint": { + "rules": { + "exclude": ["no-explicit-any"] + } + }, + "fmt": { + "singleQuote": true, + "lineWidth": 100 + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..c8691e4 --- /dev/null +++ b/mod.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env -S deno run --allow-all + +/** + * Stack.Gallery Registry + * Enterprise-grade multi-protocol package registry + */ + +import { runCli } from './ts/cli.ts'; + +// Run CLI +await runCli(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..25473de --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "@stack.gallery/registry", + "version": "1.0.0", + "private": true, + "description": "Enterprise-grade multi-protocol package registry", + "type": "module", + "scripts": { + "start": "deno run --allow-all mod.ts server", + "dev": "deno run --allow-all --watch mod.ts server --ephemeral", + "build": "cd ui && pnpm run build", + "test": "deno test --allow-all" + }, + "keywords": [ + "registry", + "npm", + "docker", + "oci", + "maven", + "cargo", + "pypi", + "rubygems", + "composer" + ], + "author": "Stack.Gallery", + "license": "MIT" +} diff --git a/ts/api/handlers/audit.api.ts b/ts/api/handlers/audit.api.ts new file mode 100644 index 0000000..3ef40d6 --- /dev/null +++ b/ts/api/handlers/audit.api.ts @@ -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 { + 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' } }; + } + } +} diff --git a/ts/api/handlers/auth.api.ts b/ts/api/handlers/auth.api.ts new file mode 100644 index 0000000..38dc09e --- /dev/null +++ b/ts/api/handlers/auth.api.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }, + }; + } +} diff --git a/ts/api/handlers/index.ts b/ts/api/handlers/index.ts new file mode 100644 index 0000000..9e17c1a --- /dev/null +++ b/ts/api/handlers/index.ts @@ -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'; diff --git a/ts/api/handlers/organization.api.ts b/ts/api/handlers/organization.api.ts new file mode 100644 index 0000000..bb518a4 --- /dev/null +++ b/ts/api/handlers/organization.api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' } }; + } + } +} diff --git a/ts/api/handlers/package.api.ts b/ts/api/handlers/package.api.ts new file mode 100644 index 0000000..d94d1f2 --- /dev/null +++ b/ts/api/handlers/package.api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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' } }; + } + } +} diff --git a/ts/api/handlers/repository.api.ts b/ts/api/handlers/repository.api.ts new file mode 100644 index 0000000..39f70f2 --- /dev/null +++ b/ts/api/handlers/repository.api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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' } }; + } + } +} diff --git a/ts/api/handlers/token.api.ts b/ts/api/handlers/token.api.ts new file mode 100644 index 0000000..b6addbd --- /dev/null +++ b/ts/api/handlers/token.api.ts @@ -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 { + 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 { + 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 { + 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' } }; + } + } +} diff --git a/ts/api/handlers/user.api.ts b/ts/api/handlers/user.api.ts new file mode 100644 index 0000000..43a2a78 --- /dev/null +++ b/ts/api/handlers/user.api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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' } }; + } + } +} diff --git a/ts/api/index.ts b/ts/api/index.ts new file mode 100644 index 0000000..61dfd9d --- /dev/null +++ b/ts/api/index.ts @@ -0,0 +1,6 @@ +/** + * API exports + */ + +export { ApiRouter, type IApiContext, type IApiResponse } from './router.ts'; +export * from './handlers/index.ts'; diff --git a/ts/api/router.ts b/ts/api/router.ts new file mode 100644 index 0000000..f3e3107 --- /dev/null +++ b/ts/api/router.ts @@ -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; + 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, + }); + } +} diff --git a/ts/cli.ts b/ts/cli.ts new file mode 100644 index 0000000..7e5916c --- /dev/null +++ b/ts/cli.ts @@ -0,0 +1,108 @@ +/** + * CLI entry point for Stack.Gallery Registry + */ + +import * as plugins from './plugins.ts'; +import { StackGalleryRegistry, createRegistryFromEnv } from './registry.ts'; +import { initDb } from './models/db.ts'; +import { User, Organization, OrganizationMember, Repository } from './models/index.ts'; +import { AuthService } from './services/auth.service.ts'; + +export async function runCli(): Promise { + const smartcliInstance = new plugins.smartcli.Smartcli(); + + // Server command + smartcliInstance.addCommand('server').subscribe(async (argsParsed) => { + console.log('Starting Stack.Gallery Registry...'); + + const registry = createRegistryFromEnv(); + await registry.start(); + + // Handle shutdown gracefully + const shutdown = async () => { + console.log('\nShutting down...'); + await registry.stop(); + Deno.exit(0); + }; + + Deno.addSignalListener('SIGINT', shutdown); + Deno.addSignalListener('SIGTERM', shutdown); + }); + + // Status command + smartcliInstance.addCommand('status').subscribe(async () => { + console.log('Stack.Gallery Registry Status'); + console.log('============================='); + // TODO: Implement status check + console.log('Status check not yet implemented'); + }); + + // User commands + smartcliInstance.addCommand('user').subscribe(async (argsParsed) => { + const subCommand = argsParsed.commandArgs[0]; + + switch (subCommand) { + case 'create': + console.log('Creating user...'); + // TODO: Implement user creation + break; + case 'list': + console.log('Listing users...'); + // TODO: Implement user listing + break; + default: + console.log('Usage: user [create|list]'); + } + }); + + // Organization commands + smartcliInstance.addCommand('org').subscribe(async (argsParsed) => { + const subCommand = argsParsed.commandArgs[0]; + + switch (subCommand) { + case 'create': + console.log('Creating organization...'); + // TODO: Implement org creation + break; + case 'list': + console.log('Listing organizations...'); + // TODO: Implement org listing + break; + default: + console.log('Usage: org [create|list]'); + } + }); + + // Default/help command + smartcliInstance.addCommand('help').subscribe(() => { + console.log(` +Stack.Gallery Registry - Enterprise Package Registry + +Usage: + registry [options] + +Commands: + server [--ephemeral] [--monitor] Start the registry server + status Check registry status + user User management + org Organization management + help Show this help message + +Options: + --ephemeral Run in ephemeral mode (in-memory database) + --monitor Enable performance monitoring + +Environment Variables: + MONGODB_URL MongoDB connection string + S3_ENDPOINT S3-compatible storage endpoint + S3_ACCESS_KEY S3 access key + S3_SECRET_KEY S3 secret key + S3_BUCKET S3 bucket name + JWT_SECRET JWT signing secret + PORT HTTP server port (default: 3000) +`); + }); + + // Parse CLI arguments + smartcliInstance.startParse(); +} diff --git a/ts/index.ts b/ts/index.ts new file mode 100644 index 0000000..1f510d2 --- /dev/null +++ b/ts/index.ts @@ -0,0 +1,19 @@ +/** + * Stack.Gallery Registry + * Enterprise-grade multi-protocol package registry + */ + +// Export interfaces +export * from './interfaces/index.ts'; + +// Export models +export * from './models/index.ts'; + +// Export services +export * from './services/index.ts'; + +// Export providers +export * from './providers/index.ts'; + +// Export main registry class +export { StackGalleryRegistry } from './registry.ts'; diff --git a/ts/interfaces/audit.interfaces.ts b/ts/interfaces/audit.interfaces.ts new file mode 100644 index 0000000..9f4cc87 --- /dev/null +++ b/ts/interfaces/audit.interfaces.ts @@ -0,0 +1,152 @@ +/** + * Audit logging interfaces + */ + +// ============================================================================= +// Audit Action Types +// ============================================================================= + +export type TAuditAction = + // Authentication + | 'AUTH_LOGIN' + | 'AUTH_LOGOUT' + | 'AUTH_FAILED' + | 'AUTH_MFA_ENABLED' + | 'AUTH_MFA_DISABLED' + | 'AUTH_PASSWORD_CHANGED' + | 'AUTH_PASSWORD_RESET' + // API Tokens + | 'TOKEN_CREATED' + | 'TOKEN_USED' + | 'TOKEN_REVOKED' + | 'TOKEN_EXPIRED' + // User Management + | 'USER_CREATED' + | 'USER_UPDATED' + | 'USER_DELETED' + | 'USER_SUSPENDED' + | 'USER_ACTIVATED' + // Organization Management + | 'ORG_CREATED' + | 'ORG_UPDATED' + | 'ORG_DELETED' + | 'ORG_MEMBER_ADDED' + | 'ORG_MEMBER_REMOVED' + | 'ORG_MEMBER_ROLE_CHANGED' + // Team Management + | 'TEAM_CREATED' + | 'TEAM_UPDATED' + | 'TEAM_DELETED' + | 'TEAM_MEMBER_ADDED' + | 'TEAM_MEMBER_REMOVED' + // Repository Management + | 'REPO_CREATED' + | 'REPO_UPDATED' + | 'REPO_DELETED' + | 'REPO_VISIBILITY_CHANGED' + | 'REPO_PERMISSION_GRANTED' + | 'REPO_PERMISSION_REVOKED' + // Package Operations + | 'PACKAGE_PUSHED' + | 'PACKAGE_PULLED' + | 'PACKAGE_DELETED' + | 'PACKAGE_DEPRECATED' + // Security Events + | 'SECURITY_SCAN_COMPLETED' + | 'SECURITY_VULNERABILITY_FOUND' + | 'SECURITY_IP_BLOCKED' + | 'SECURITY_RATE_LIMITED'; + +export type TAuditResourceType = + | 'user' + | 'organization' + | 'team' + | 'repository' + | 'package' + | 'api_token' + | 'session' + | 'system'; + +// ============================================================================= +// Audit Log Entry +// ============================================================================= + +export interface IAuditLog { + id: string; + actorId?: string; + actorType: 'user' | 'api_token' | 'system' | 'anonymous'; + actorTokenId?: string; + actorIp?: string; + actorUserAgent?: string; + action: TAuditAction; + resourceType: TAuditResourceType; + resourceId?: string; + resourceName?: string; + organizationId?: string; + repositoryId?: string; + metadata: Record; + success: boolean; + errorCode?: string; + errorMessage?: string; + durationMs?: number; + timestamp: Date; +} + +// ============================================================================= +// Audit Query Types +// ============================================================================= + +export interface IAuditQuery { + actorId?: string; + organizationId?: string; + repositoryId?: string; + resourceType?: TAuditResourceType; + action?: TAuditAction[]; + success?: boolean; + startDate?: Date; + endDate?: Date; + offset?: number; + limit?: number; +} + +export interface IAuditQueryResult { + logs: IAuditLog[]; + total: number; + offset: number; + limit: number; +} + +// ============================================================================= +// Audit Event (for logging) +// ============================================================================= + +export interface IAuditEvent { + actorId?: string; + actorType?: 'user' | 'api_token' | 'system' | 'anonymous'; + actorTokenId?: string; + actorIp?: string; + actorUserAgent?: string; + action: TAuditAction; + resourceType: TAuditResourceType; + resourceId?: string; + resourceName?: string; + organizationId?: string; + repositoryId?: string; + metadata?: Record; + success?: boolean; + errorCode?: string; + errorMessage?: string; + durationMs?: number; +} + +// ============================================================================= +// Token Activity +// ============================================================================= + +export interface ITokenActivitySummary { + tokenId: string; + totalActions: number; + lastUsed?: Date; + actionBreakdown: Record; + ipAddresses: string[]; +} diff --git a/ts/interfaces/auth.interfaces.ts b/ts/interfaces/auth.interfaces.ts new file mode 100644 index 0000000..3b033a7 --- /dev/null +++ b/ts/interfaces/auth.interfaces.ts @@ -0,0 +1,282 @@ +/** + * Authentication and authorization interfaces + */ + +// ============================================================================= +// User Types +// ============================================================================= + +export type TUserStatus = 'active' | 'suspended' | 'pending_verification'; + +export interface IUser { + id: string; + email: string; + username: string; + passwordHash: string; + displayName: string; + avatarUrl?: string; + status: TUserStatus; + emailVerified: boolean; + mfaEnabled: boolean; + mfaSecret?: string; + lastLoginAt?: Date; + lastLoginIp?: string; + failedLoginAttempts: number; + lockedUntil?: Date; + isPlatformAdmin: boolean; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================= +// Organization Types +// ============================================================================= + +export type TOrganizationPlan = 'free' | 'team' | 'enterprise'; +export type TOrganizationRole = 'owner' | 'admin' | 'member'; + +export interface IOrganizationSettings { + requireMfa: boolean; + allowPublicRepositories: boolean; + defaultRepositoryVisibility: TRepositoryVisibility; + allowedProtocols: TRegistryProtocol[]; +} + +export interface IOrganization { + id: string; + name: string; // URL-safe slug + displayName: string; + description?: string; + avatarUrl?: string; + plan: TOrganizationPlan; + settings: IOrganizationSettings; + billingEmail?: string; + isVerified: boolean; + verifiedDomains: string[]; + storageQuotaBytes: number; + usedStorageBytes: number; + createdAt: Date; + updatedAt: Date; + createdById: string; +} + +export interface IOrganizationMember { + id: string; + organizationId: string; + userId: string; + role: TOrganizationRole; + invitedBy?: string; + joinedAt: Date; + createdAt: Date; +} + +// ============================================================================= +// Team Types +// ============================================================================= + +export type TTeamRole = 'maintainer' | 'member'; + +export interface ITeam { + id: string; + organizationId: string; + name: string; + description?: string; + isDefaultTeam: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface ITeamMember { + id: string; + teamId: string; + userId: string; + role: TTeamRole; + createdAt: Date; +} + +// ============================================================================= +// Repository Types +// ============================================================================= + +export type TRepositoryVisibility = 'public' | 'private' | 'internal'; +export type TRepositoryRole = 'admin' | 'maintainer' | 'developer' | 'reader'; +export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems'; + +export interface IRepository { + id: string; + organizationId: string; + name: string; + description?: string; + protocol: TRegistryProtocol; + visibility: TRepositoryVisibility; + storageNamespace: string; + downloadCount: number; + starCount: number; + createdAt: Date; + updatedAt: Date; + createdById: string; +} + +export interface IRepositoryPermission { + id: string; + repositoryId: string; + teamId?: string; + userId?: string; + role: TRepositoryRole; + createdAt: Date; + grantedById: string; +} + +// ============================================================================= +// Token Types +// ============================================================================= + +export interface ITokenScope { + protocol: TRegistryProtocol | '*'; + organizationId?: string; + repositoryId?: string; + actions: TTokenAction[]; +} + +export type TTokenAction = 'read' | 'write' | 'delete' | '*'; + +export interface IApiToken { + id: string; + userId: string; + name: string; + tokenHash: string; + tokenPrefix: string; + protocols: TRegistryProtocol[]; + scopes: ITokenScope[]; + expiresAt?: Date; + lastUsedAt?: Date; + lastUsedIp?: string; + usageCount: number; + isRevoked: boolean; + revokedAt?: Date; + revokedReason?: string; + createdAt: Date; + createdIp?: string; +} + +// ============================================================================= +// Session Types +// ============================================================================= + +export interface ISession { + id: string; + userId: string; + userAgent: string; + ipAddress: string; + isValid: boolean; + invalidatedAt?: Date; + invalidatedReason?: string; + lastActivityAt: Date; + createdAt: Date; +} + +// ============================================================================= +// JWT Types +// ============================================================================= + +export interface IJwtPayload { + sub: string; // User ID + iss: string; // Issuer + aud: string; // Audience + exp: number; // Expiration + iat: number; // Issued at + nbf: number; // Not before + type: 'access' | 'refresh'; + email: string; + username: string; + orgs: Array<{ + id: string; + name: string; + role: TOrganizationRole; + }>; + sessionId: string; +} + +// ============================================================================= +// Auth Results +// ============================================================================= + +export interface IAuthResult { + accessToken: string; + refreshToken: string; + expiresIn: number; + user: IUser; +} + +export interface IValidatedToken { + tokenId: string; + userId: string; + username: string; + protocols: TRegistryProtocol[]; + scopes: ITokenScope[]; +} + +export interface IAuthorizationResult { + authorized: boolean; + reason?: string; + userId?: string; +} + +// ============================================================================= +// Permission Types +// ============================================================================= + +export type TPermissionAction = + | 'repo:read' + | 'repo:write' + | 'repo:delete' + | 'repo:admin' + | 'team:read' + | 'team:write' + | 'team:admin' + | 'org:read' + | 'org:write' + | 'org:admin' + | 'token:create' + | 'token:revoke'; + +export interface IResource { + type: 'repository' | 'organization' | 'team' | 'user'; + id: string; +} + +// ============================================================================= +// Create/Update DTOs +// ============================================================================= + +export interface ICreateUserDto { + email: string; + username: string; + password: string; + displayName?: string; +} + +export interface ICreateOrganizationDto { + name: string; + displayName: string; + description?: string; +} + +export interface ICreateTeamDto { + name: string; + description?: string; +} + +export interface ICreateRepositoryDto { + name: string; + description?: string; + protocol: TRegistryProtocol; + visibility?: TRepositoryVisibility; +} + +export interface ICreateTokenDto { + name: string; + protocols: TRegistryProtocol[]; + scopes: ITokenScope[]; + expiresAt?: Date; +} diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts new file mode 100644 index 0000000..6a3c7e3 --- /dev/null +++ b/ts/interfaces/index.ts @@ -0,0 +1,7 @@ +/** + * Type definitions for Stack.Gallery Registry + */ + +export * from './auth.interfaces.ts'; +export * from './package.interfaces.ts'; +export * from './audit.interfaces.ts'; diff --git a/ts/interfaces/package.interfaces.ts b/ts/interfaces/package.interfaces.ts new file mode 100644 index 0000000..314dd39 --- /dev/null +++ b/ts/interfaces/package.interfaces.ts @@ -0,0 +1,202 @@ +/** + * Package and artifact interfaces + */ + +import type { TRegistryProtocol } from './auth.interfaces.ts'; + +// ============================================================================= +// Package Types +// ============================================================================= + +export interface IPackage { + id: string; // {protocol}:{org}:{name} + organizationId: string; + repositoryId: string; + protocol: TRegistryProtocol; + name: string; + description?: string; + versions: Record; + distTags: Record; // npm dist-tags, e.g., { latest: "1.0.0" } + metadata: IProtocolMetadata; + isPrivate: boolean; + storageBytes: number; + downloadCount: number; + starCount: number; + cacheExpiresAt?: Date; + createdAt: Date; + updatedAt: Date; + createdById: string; +} + +export interface IPackageVersion { + version: string; + digest?: string; // Content-addressable digest (sha256:...) + size: number; + publishedAt: Date; + publishedById: string; + deprecated?: boolean; + deprecationMessage?: string; + downloads: number; + metadata: IVersionMetadata; +} + +// ============================================================================= +// Protocol-Specific Metadata +// ============================================================================= + +export type IProtocolMetadata = + | INpmMetadata + | IOciMetadata + | IMavenMetadata + | ICargoMetadata + | IComposerMetadata + | IPypiMetadata + | IRubygemsMetadata; + +export interface INpmMetadata { + type: 'npm'; + scope?: string; + keywords?: string[]; + license?: string; + repository?: { + type: string; + url: string; + }; + homepage?: string; + bugs?: string; + author?: string | { name: string; email?: string; url?: string }; + maintainers?: Array<{ name: string; email?: string }>; +} + +export interface IOciMetadata { + type: 'oci'; + mediaType: string; + tags: string[]; + architecture?: string; + os?: string; + annotations?: Record; +} + +export interface IMavenMetadata { + type: 'maven'; + groupId: string; + artifactId: string; + packaging: string; + classifier?: string; + parent?: { + groupId: string; + artifactId: string; + version: string; + }; +} + +export interface ICargoMetadata { + type: 'cargo'; + features: Record; + dependencies: Array<{ + name: string; + req: string; + features: string[]; + optional: boolean; + defaultFeatures: boolean; + target?: string; + kind: 'normal' | 'dev' | 'build'; + }>; + keywords?: string[]; + categories?: string[]; + license?: string; + links?: string; +} + +export interface IComposerMetadata { + type: 'composer'; + vendor: string; + packageType?: string; + license?: string | string[]; + require?: Record; + requireDev?: Record; + autoload?: Record; +} + +export interface IPypiMetadata { + type: 'pypi'; + classifiers?: string[]; + requiresPython?: string; + requiresDist?: string[]; + providesExtra?: string[]; + projectUrls?: Record; +} + +export interface IRubygemsMetadata { + type: 'rubygems'; + platform?: string; + requiredRubyVersion?: string; + requiredRubygemsVersion?: string; + dependencies?: Array<{ + name: string; + requirements: string; + type: 'runtime' | 'development'; + }>; +} + +// ============================================================================= +// Version Metadata +// ============================================================================= + +export interface IVersionMetadata { + readme?: string; + changelog?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + engines?: Record; + files?: string[]; + checksum?: { + sha256?: string; + sha512?: string; + md5?: string; + }; +} + +// ============================================================================= +// Search Types +// ============================================================================= + +export interface IPackageSearchParams { + query?: string; + protocol?: TRegistryProtocol; + organizationId?: string; + visibility?: 'public' | 'private' | 'internal'; + sort?: 'downloads' | 'stars' | 'updated' | 'name'; + order?: 'asc' | 'desc'; + offset?: number; + limit?: number; +} + +export interface IPackageSearchResult { + packages: IPackage[]; + total: number; + offset: number; + limit: number; +} + +// ============================================================================= +// Stats Types +// ============================================================================= + +export interface IPackageStats { + packageId: string; + totalDownloads: number; + downloadsByVersion: Record; + downloadsByDay: Array<{ date: string; count: number }>; + downloadsByCountry?: Record; +} + +export interface IOrganizationStats { + organizationId: string; + totalPackages: number; + totalDownloads: number; + storageUsedBytes: number; + storageQuotaBytes: number; + packagesByProtocol: Record; +} diff --git a/ts/models/apitoken.ts b/ts/models/apitoken.ts new file mode 100644 index 0000000..e796fdd --- /dev/null +++ b/ts/models/apitoken.ts @@ -0,0 +1,167 @@ +/** + * ApiToken model for Stack.Gallery Registry + */ + +import * as plugins from '../plugins.ts'; +import type { IApiToken, ITokenScope, TRegistryProtocol } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class ApiToken + extends plugins.smartdata.SmartDataDbDoc + implements IApiToken +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public userId: string = ''; + + @plugins.smartdata.svDb() + public name: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index({ unique: true }) + public tokenHash: string = ''; + + @plugins.smartdata.svDb() + public tokenPrefix: string = ''; + + @plugins.smartdata.svDb() + public protocols: TRegistryProtocol[] = []; + + @plugins.smartdata.svDb() + public scopes: ITokenScope[] = []; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public expiresAt?: Date; + + @plugins.smartdata.svDb() + public lastUsedAt?: Date; + + @plugins.smartdata.svDb() + public lastUsedIp?: string; + + @plugins.smartdata.svDb() + public usageCount: number = 0; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public isRevoked: boolean = false; + + @plugins.smartdata.svDb() + public revokedAt?: Date; + + @plugins.smartdata.svDb() + public revokedReason?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + @plugins.smartdata.svDb() + public createdIp?: string; + + /** + * Find token by hash + */ + public static async findByHash(tokenHash: string): Promise { + return await ApiToken.getInstance({ + tokenHash, + isRevoked: false, + }); + } + + /** + * Find token by prefix (for listing) + */ + public static async findByPrefix(tokenPrefix: string): Promise { + return await ApiToken.getInstance({ + tokenPrefix, + }); + } + + /** + * Get all tokens for a user + */ + public static async getUserTokens(userId: string): Promise { + return await ApiToken.getInstances({ + userId, + isRevoked: false, + }); + } + + /** + * Check if token is valid (not expired, not revoked) + */ + public isValid(): boolean { + if (this.isRevoked) return false; + if (this.expiresAt && this.expiresAt < new Date()) return false; + return true; + } + + /** + * Record token usage + */ + public async recordUsage(ip?: string): Promise { + this.lastUsedAt = new Date(); + this.lastUsedIp = ip; + this.usageCount += 1; + await this.save(); + } + + /** + * Revoke token + */ + public async revoke(reason?: string): Promise { + this.isRevoked = true; + this.revokedAt = new Date(); + this.revokedReason = reason; + await this.save(); + } + + /** + * Check if token has permission for protocol + */ + public hasProtocol(protocol: TRegistryProtocol): boolean { + return this.protocols.includes(protocol) || this.protocols.includes('*' as TRegistryProtocol); + } + + /** + * Check if token has permission for action on resource + */ + public hasScope( + protocol: TRegistryProtocol, + organizationId?: string, + repositoryId?: string, + action?: string + ): boolean { + for (const scope of this.scopes) { + // Check protocol + if (scope.protocol !== '*' && scope.protocol !== protocol) continue; + + // Check organization + if (scope.organizationId && scope.organizationId !== organizationId) continue; + + // Check repository + if (scope.repositoryId && scope.repositoryId !== repositoryId) continue; + + // Check action + if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) continue; + + return true; + } + return false; + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + if (!this.id) { + this.id = await ApiToken.getNewId(); + } + } +} diff --git a/ts/models/auditlog.ts b/ts/models/auditlog.ts new file mode 100644 index 0000000..677aed3 --- /dev/null +++ b/ts/models/auditlog.ts @@ -0,0 +1,171 @@ +/** + * AuditLog model for Stack.Gallery Registry + */ + +import * as plugins from '../plugins.ts'; +import type { IAuditLog, TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class AuditLog + extends plugins.smartdata.SmartDataDbDoc + implements IAuditLog +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public actorId?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public actorType: 'user' | 'api_token' | 'system' | 'anonymous' = 'anonymous'; + + @plugins.smartdata.svDb() + public actorTokenId?: string; + + @plugins.smartdata.svDb() + public actorIp?: string; + + @plugins.smartdata.svDb() + public actorUserAgent?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public action: TAuditAction = 'USER_CREATED'; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public resourceType: TAuditResourceType = 'user'; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public resourceId?: string; + + @plugins.smartdata.svDb() + public resourceName?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public organizationId?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public repositoryId?: string; + + @plugins.smartdata.svDb() + public metadata: Record = {}; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public success: boolean = true; + + @plugins.smartdata.svDb() + public errorCode?: string; + + @plugins.smartdata.svDb() + public errorMessage?: string; + + @plugins.smartdata.svDb() + public durationMs?: number; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public timestamp: Date = new Date(); + + /** + * Create an audit log entry + */ + public static async log(data: { + actorId?: string; + actorType?: 'user' | 'api_token' | 'system' | 'anonymous'; + actorTokenId?: string; + actorIp?: string; + actorUserAgent?: string; + action: TAuditAction; + resourceType: TAuditResourceType; + resourceId?: string; + resourceName?: string; + organizationId?: string; + repositoryId?: string; + metadata?: Record; + success?: boolean; + errorCode?: string; + errorMessage?: string; + durationMs?: number; + }): Promise { + const log = new AuditLog(); + log.id = await AuditLog.getNewId(); + log.actorId = data.actorId; + log.actorType = data.actorType || (data.actorId ? 'user' : 'anonymous'); + log.actorTokenId = data.actorTokenId; + log.actorIp = data.actorIp; + log.actorUserAgent = data.actorUserAgent; + log.action = data.action; + log.resourceType = data.resourceType; + log.resourceId = data.resourceId; + log.resourceName = data.resourceName; + log.organizationId = data.organizationId; + log.repositoryId = data.repositoryId; + log.metadata = data.metadata || {}; + log.success = data.success ?? true; + log.errorCode = data.errorCode; + log.errorMessage = data.errorMessage; + log.durationMs = data.durationMs; + log.timestamp = new Date(); + await log.save(); + return log; + } + + /** + * Query audit logs with filters + */ + public static async query(filters: { + actorId?: string; + organizationId?: string; + repositoryId?: string; + resourceType?: TAuditResourceType; + action?: TAuditAction[]; + success?: boolean; + startDate?: Date; + endDate?: Date; + offset?: number; + limit?: number; + }): Promise<{ logs: AuditLog[]; total: number }> { + const query: Record = {}; + + if (filters.actorId) query.actorId = filters.actorId; + if (filters.organizationId) query.organizationId = filters.organizationId; + if (filters.repositoryId) query.repositoryId = filters.repositoryId; + if (filters.resourceType) query.resourceType = filters.resourceType; + if (filters.action) query.action = { $in: filters.action }; + if (filters.success !== undefined) query.success = filters.success; + + if (filters.startDate || filters.endDate) { + query.timestamp = {}; + if (filters.startDate) (query.timestamp as Record).$gte = filters.startDate; + if (filters.endDate) (query.timestamp as Record).$lte = filters.endDate; + } + + // Get total count + const allLogs = await AuditLog.getInstances(query); + const total = allLogs.length; + + // Apply pagination + const offset = filters.offset || 0; + const limit = filters.limit || 100; + const logs = allLogs.slice(offset, offset + limit); + + return { logs, total }; + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + if (!this.id) { + this.id = await AuditLog.getNewId(); + } + } +} diff --git a/ts/models/db.ts b/ts/models/db.ts new file mode 100644 index 0000000..ed9e122 --- /dev/null +++ b/ts/models/db.ts @@ -0,0 +1,57 @@ +/** + * Database connection singleton + */ + +import * as plugins from '../plugins.ts'; + +let dbInstance: plugins.smartdata.SmartdataDb | null = null; + +/** + * Initialize database connection + */ +export async function initDb(config: { + mongoDbUrl: string; + mongoDbName?: string; +}): Promise { + if (dbInstance) { + return dbInstance; + } + + dbInstance = new plugins.smartdata.SmartdataDb({ + mongoDbUrl: config.mongoDbUrl, + mongoDbName: config.mongoDbName || 'stackregistry', + }); + + await dbInstance.init(); + console.log('Database connected successfully'); + + return dbInstance; +} + +/** + * Get database instance (must call initDb first) + */ +export function getDb(): plugins.smartdata.SmartdataDb { + if (!dbInstance) { + throw new Error('Database not initialized. Call initDb() first.'); + } + return dbInstance; +} + +/** + * Close database connection + */ +export async function closeDb(): Promise { + if (dbInstance) { + await dbInstance.close(); + dbInstance = null; + console.log('Database connection closed'); + } +} + +/** + * Check if database is connected + */ +export function isDbConnected(): boolean { + return dbInstance !== null; +} diff --git a/ts/models/index.ts b/ts/models/index.ts new file mode 100644 index 0000000..7b7fda0 --- /dev/null +++ b/ts/models/index.ts @@ -0,0 +1,16 @@ +/** + * Model exports + */ + +export { initDb, getDb, closeDb, isDbConnected } from './db.ts'; +export { User } from './user.ts'; +export { Organization } from './organization.ts'; +export { OrganizationMember } from './organization.member.ts'; +export { Team } from './team.ts'; +export { TeamMember } from './team.member.ts'; +export { Repository } from './repository.ts'; +export { RepositoryPermission } from './repository.permission.ts'; +export { Package } from './package.ts'; +export { ApiToken } from './apitoken.ts'; +export { Session } from './session.ts'; +export { AuditLog } from './auditlog.ts'; diff --git a/ts/models/organization.member.ts b/ts/models/organization.member.ts new file mode 100644 index 0000000..f5e79b2 --- /dev/null +++ b/ts/models/organization.member.ts @@ -0,0 +1,109 @@ +/** + * OrganizationMember model - links users to organizations with roles + */ + +import * as plugins from '../plugins.ts'; +import type { IOrganizationMember, TOrganizationRole } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class OrganizationMember + extends plugins.smartdata.SmartDataDbDoc + implements IOrganizationMember +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public organizationId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public userId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public role: TOrganizationRole = 'member'; + + @plugins.smartdata.svDb() + public invitedBy?: string; + + @plugins.smartdata.svDb() + public joinedAt: Date = new Date(); + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + /** + * Add a member to an organization + */ + public static async addMember(data: { + organizationId: string; + userId: string; + role: TOrganizationRole; + invitedBy?: string; + }): Promise { + // Check if member already exists + const existing = await OrganizationMember.getInstance({ + organizationId: data.organizationId, + userId: data.userId, + }); + + if (existing) { + throw new Error('User is already a member of this organization'); + } + + const member = new OrganizationMember(); + member.id = await OrganizationMember.getNewId(); + member.organizationId = data.organizationId; + member.userId = data.userId; + member.role = data.role; + member.invitedBy = data.invitedBy; + member.joinedAt = new Date(); + member.createdAt = new Date(); + await member.save(); + return member; + } + + /** + * Find membership for user in organization + */ + public static async findMembership( + organizationId: string, + userId: string + ): Promise { + return await OrganizationMember.getInstance({ + organizationId, + userId, + }); + } + + /** + * Get all members of an organization + */ + public static async getOrgMembers(organizationId: string): Promise { + return await OrganizationMember.getInstances({ + organizationId, + }); + } + + /** + * Get all organizations a user belongs to + */ + public static async getUserOrganizations(userId: string): Promise { + return await OrganizationMember.getInstances({ + userId, + }); + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + if (!this.id) { + this.id = await OrganizationMember.getNewId(); + } + } +} diff --git a/ts/models/organization.ts b/ts/models/organization.ts new file mode 100644 index 0000000..72e8409 --- /dev/null +++ b/ts/models/organization.ts @@ -0,0 +1,138 @@ +/** + * Organization model for Stack.Gallery Registry + */ + +import * as plugins from '../plugins.ts'; +import type { + IOrganization, + IOrganizationSettings, + TOrganizationPlan, +} from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +const DEFAULT_SETTINGS: IOrganizationSettings = { + requireMfa: false, + allowPublicRepositories: true, + defaultRepositoryVisibility: 'private', + allowedProtocols: ['oci', 'npm', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'], +}; + +@plugins.smartdata.Collection(() => getDb()) +export class Organization + extends plugins.smartdata.SmartDataDbDoc + implements IOrganization +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + @plugins.smartdata.index({ unique: true }) + public name: string = ''; // URL-safe slug + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + public displayName: string = ''; + + @plugins.smartdata.svDb() + public description?: string; + + @plugins.smartdata.svDb() + public avatarUrl?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public plan: TOrganizationPlan = 'free'; + + @plugins.smartdata.svDb() + public settings: IOrganizationSettings = DEFAULT_SETTINGS; + + @plugins.smartdata.svDb() + public billingEmail?: string; + + @plugins.smartdata.svDb() + public isVerified: boolean = false; + + @plugins.smartdata.svDb() + public verifiedDomains: string[] = []; + + @plugins.smartdata.svDb() + public storageQuotaBytes: number = 5 * 1024 * 1024 * 1024; // 5GB default + + @plugins.smartdata.svDb() + public usedStorageBytes: number = 0; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + @plugins.smartdata.svDb() + public updatedAt: Date = new Date(); + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdById: string = ''; + + /** + * Create a new organization + */ + public static async createOrganization(data: { + name: string; + displayName: string; + description?: string; + createdById: string; + }): Promise { + // Validate name (URL-safe) + const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + if (!nameRegex.test(data.name)) { + throw new Error( + 'Organization name must be lowercase alphanumeric with optional hyphens' + ); + } + + const org = new Organization(); + org.id = await Organization.getNewId(); + org.name = data.name.toLowerCase(); + org.displayName = data.displayName; + org.description = data.description; + org.createdById = data.createdById; + org.settings = { ...DEFAULT_SETTINGS }; + org.createdAt = new Date(); + org.updatedAt = new Date(); + await org.save(); + return org; + } + + /** + * Find organization by name (slug) + */ + public static async findByName(name: string): Promise { + return await Organization.getInstance({ name: name.toLowerCase() }); + } + + /** + * Check if storage quota is exceeded + */ + public hasStorageAvailable(additionalBytes: number): boolean { + if (this.storageQuotaBytes < 0) return true; // Unlimited + return this.usedStorageBytes + additionalBytes <= this.storageQuotaBytes; + } + + /** + * Update storage usage + */ + public async updateStorageUsage(deltaBytes: number): Promise { + this.usedStorageBytes = Math.max(0, this.usedStorageBytes + deltaBytes); + await this.save(); + } + + /** + * Lifecycle hook: Update timestamps before save + */ + public async beforeSave(): Promise { + this.updatedAt = new Date(); + if (!this.id) { + this.id = await Organization.getNewId(); + } + } +} diff --git a/ts/models/package.ts b/ts/models/package.ts new file mode 100644 index 0000000..6d5a3ae --- /dev/null +++ b/ts/models/package.ts @@ -0,0 +1,195 @@ +/** + * Package model for Stack.Gallery Registry + */ + +import * as plugins from '../plugins.ts'; +import type { + IPackage, + IPackageVersion, + IProtocolMetadata, +} from '../interfaces/package.interfaces.ts'; +import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class Package extends plugins.smartdata.SmartDataDbDoc implements IPackage { + @plugins.smartdata.unI() + public id: string = ''; // {protocol}:{org}:{name} + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public organizationId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public repositoryId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public protocol: TRegistryProtocol = 'npm'; + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + @plugins.smartdata.index() + public name: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + public description?: string; + + @plugins.smartdata.svDb() + public versions: Record = {}; + + @plugins.smartdata.svDb() + public distTags: Record = {}; // e.g., { latest: "1.0.0" } + + @plugins.smartdata.svDb() + public metadata: IProtocolMetadata = { type: 'npm' }; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public isPrivate: boolean = true; + + @plugins.smartdata.svDb() + public storageBytes: number = 0; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public downloadCount: number = 0; + + @plugins.smartdata.svDb() + public starCount: number = 0; + + @plugins.smartdata.svDb() + public cacheExpiresAt?: Date; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + @plugins.smartdata.svDb() + public updatedAt: Date = new Date(); + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdById: string = ''; + + /** + * Generate package ID + */ + public static generateId(protocol: TRegistryProtocol, orgName: string, name: string): string { + return `${protocol}:${orgName}:${name}`; + } + + /** + * Find package by ID + */ + public static async findById(id: string): Promise { + return await Package.getInstance({ id }); + } + + /** + * Find package by protocol, org, and name + */ + public static async findByName( + protocol: TRegistryProtocol, + orgName: string, + name: string + ): Promise { + const id = Package.generateId(protocol, orgName, name); + return await Package.findById(id); + } + + /** + * Get packages in an organization + */ + public static async getOrgPackages(organizationId: string): Promise { + return await Package.getInstances({ organizationId }); + } + + /** + * Search packages + */ + public static async search( + query: string, + options?: { + protocol?: TRegistryProtocol; + organizationId?: string; + isPrivate?: boolean; + limit?: number; + offset?: number; + } + ): Promise { + const filter: Record = {}; + if (options?.protocol) filter.protocol = options.protocol; + if (options?.organizationId) filter.organizationId = options.organizationId; + if (options?.isPrivate !== undefined) filter.isPrivate = options.isPrivate; + + // Simple text search - in production, would use MongoDB text index + const allPackages = await Package.getInstances(filter); + + // Filter by query + const lowerQuery = query.toLowerCase(); + const filtered = allPackages.filter( + (pkg) => + pkg.name.toLowerCase().includes(lowerQuery) || + pkg.description?.toLowerCase().includes(lowerQuery) + ); + + // Apply pagination + const offset = options?.offset || 0; + const limit = options?.limit || 50; + return filtered.slice(offset, offset + limit); + } + + /** + * Add a new version + */ + public addVersion(version: IPackageVersion): void { + this.versions[version.version] = version; + this.storageBytes += version.size; + this.updatedAt = new Date(); + } + + /** + * Get specific version + */ + public getVersion(version: string): IPackageVersion | undefined { + return this.versions[version]; + } + + /** + * Get latest version + */ + public getLatestVersion(): IPackageVersion | undefined { + const latest = this.distTags['latest']; + if (latest) { + return this.versions[latest]; + } + // Fallback to most recent + const versionList = Object.keys(this.versions); + if (versionList.length === 0) return undefined; + return this.versions[versionList[versionList.length - 1]]; + } + + /** + * Increment download count + */ + public async incrementDownloads(version?: string): Promise { + this.downloadCount += 1; + if (version && this.versions[version]) { + this.versions[version].downloads += 1; + } + await this.save(); + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + this.updatedAt = new Date(); + if (!this.id) { + this.id = await Package.getNewId(); + } + } +} diff --git a/ts/models/repository.permission.ts b/ts/models/repository.permission.ts new file mode 100644 index 0000000..000c73f --- /dev/null +++ b/ts/models/repository.permission.ts @@ -0,0 +1,162 @@ +/** + * RepositoryPermission model - grants access to repositories + */ + +import * as plugins from '../plugins.ts'; +import type { IRepositoryPermission, TRepositoryRole } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class RepositoryPermission + extends plugins.smartdata.SmartDataDbDoc + implements IRepositoryPermission +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public repositoryId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public teamId?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public userId?: string; + + @plugins.smartdata.svDb() + public role: TRepositoryRole = 'reader'; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + @plugins.smartdata.svDb() + public grantedById: string = ''; + + /** + * Grant permission to a user + */ + public static async grantToUser(data: { + repositoryId: string; + userId: string; + role: TRepositoryRole; + grantedById: string; + }): Promise { + // Check for existing permission + const existing = await RepositoryPermission.getInstance({ + repositoryId: data.repositoryId, + userId: data.userId, + }); + + if (existing) { + // Update existing permission + existing.role = data.role; + await existing.save(); + return existing; + } + + const perm = new RepositoryPermission(); + perm.id = await RepositoryPermission.getNewId(); + perm.repositoryId = data.repositoryId; + perm.userId = data.userId; + perm.role = data.role; + perm.grantedById = data.grantedById; + perm.createdAt = new Date(); + await perm.save(); + return perm; + } + + /** + * Grant permission to a team + */ + public static async grantToTeam(data: { + repositoryId: string; + teamId: string; + role: TRepositoryRole; + grantedById: string; + }): Promise { + // Check for existing permission + const existing = await RepositoryPermission.getInstance({ + repositoryId: data.repositoryId, + teamId: data.teamId, + }); + + if (existing) { + // Update existing permission + existing.role = data.role; + await existing.save(); + return existing; + } + + const perm = new RepositoryPermission(); + perm.id = await RepositoryPermission.getNewId(); + perm.repositoryId = data.repositoryId; + perm.teamId = data.teamId; + perm.role = data.role; + perm.grantedById = data.grantedById; + perm.createdAt = new Date(); + await perm.save(); + return perm; + } + + /** + * Get user's direct permission on repository + */ + public static async getUserPermission( + repositoryId: string, + userId: string + ): Promise { + return await RepositoryPermission.getInstance({ + repositoryId, + userId, + }); + } + + /** + * Get team's permission on repository + */ + public static async getTeamPermission( + repositoryId: string, + teamId: string + ): Promise { + return await RepositoryPermission.getInstance({ + repositoryId, + teamId, + }); + } + + /** + * Get all permissions for a repository + */ + public static async getRepoPermissions(repositoryId: string): Promise { + return await RepositoryPermission.getInstances({ + repositoryId, + }); + } + + /** + * Get all permissions for user's teams on a repository + */ + public static async getTeamPermissionsForRepo( + repositoryId: string, + teamIds: string[] + ): Promise { + if (teamIds.length === 0) return []; + return await RepositoryPermission.getInstances({ + repositoryId, + teamId: { $in: teamIds } as unknown as string, + }); + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + if (!this.id) { + this.id = await RepositoryPermission.getNewId(); + } + } +} diff --git a/ts/models/repository.ts b/ts/models/repository.ts new file mode 100644 index 0000000..5419152 --- /dev/null +++ b/ts/models/repository.ts @@ -0,0 +1,158 @@ +/** + * Repository model for Stack.Gallery Registry + */ + +import * as plugins from '../plugins.ts'; +import type { IRepository, TRepositoryVisibility, TRegistryProtocol } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class Repository + extends plugins.smartdata.SmartDataDbDoc + implements IRepository +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public organizationId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + public name: string = ''; + + @plugins.smartdata.svDb() + public description?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public protocol: TRegistryProtocol = 'npm'; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public visibility: TRepositoryVisibility = 'private'; + + @plugins.smartdata.svDb() + public storageNamespace: string = ''; + + @plugins.smartdata.svDb() + public downloadCount: number = 0; + + @plugins.smartdata.svDb() + public starCount: number = 0; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + @plugins.smartdata.svDb() + public updatedAt: Date = new Date(); + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdById: string = ''; + + /** + * Create a new repository + */ + public static async createRepository(data: { + organizationId: string; + name: string; + description?: string; + protocol: TRegistryProtocol; + visibility?: TRepositoryVisibility; + createdById: string; + }): Promise { + // Validate name + const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/; + if (!nameRegex.test(data.name.toLowerCase())) { + throw new Error('Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores'); + } + + // Check for duplicate name in org + protocol + const existing = await Repository.getInstance({ + organizationId: data.organizationId, + name: data.name.toLowerCase(), + protocol: data.protocol, + }); + + if (existing) { + throw new Error('Repository with this name and protocol already exists'); + } + + const repo = new Repository(); + repo.id = await Repository.getNewId(); + repo.organizationId = data.organizationId; + repo.name = data.name.toLowerCase(); + repo.description = data.description; + repo.protocol = data.protocol; + repo.visibility = data.visibility || 'private'; + repo.storageNamespace = `${data.protocol}/${data.organizationId}/${data.name.toLowerCase()}`; + repo.createdById = data.createdById; + repo.createdAt = new Date(); + repo.updatedAt = new Date(); + await repo.save(); + return repo; + } + + /** + * Find repository by org, name, and protocol + */ + public static async findByName( + organizationId: string, + name: string, + protocol: TRegistryProtocol + ): Promise { + return await Repository.getInstance({ + organizationId, + name: name.toLowerCase(), + protocol, + }); + } + + /** + * Get all repositories in an organization + */ + public static async getOrgRepositories(organizationId: string): Promise { + return await Repository.getInstances({ + organizationId, + }); + } + + /** + * Get all public repositories + */ + public static async getPublicRepositories(protocol?: TRegistryProtocol): Promise { + const query: Record = { visibility: 'public' }; + if (protocol) { + query.protocol = protocol; + } + return await Repository.getInstances(query); + } + + /** + * Increment download count + */ + public async incrementDownloads(): Promise { + this.downloadCount += 1; + await this.save(); + } + + /** + * Get full path (org/repo) + */ + public getFullPath(orgName: string): string { + return `${orgName}/${this.name}`; + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + this.updatedAt = new Date(); + if (!this.id) { + this.id = await Repository.getNewId(); + } + } +} diff --git a/ts/models/session.ts b/ts/models/session.ts new file mode 100644 index 0000000..b6db23f --- /dev/null +++ b/ts/models/session.ts @@ -0,0 +1,135 @@ +/** + * Session model for Stack.Gallery Registry + */ + +import * as plugins from '../plugins.ts'; +import type { ISession } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class Session + extends plugins.smartdata.SmartDataDbDoc + implements ISession +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public userId: string = ''; + + @plugins.smartdata.svDb() + public userAgent: string = ''; + + @plugins.smartdata.svDb() + public ipAddress: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public isValid: boolean = true; + + @plugins.smartdata.svDb() + public invalidatedAt?: Date; + + @plugins.smartdata.svDb() + public invalidatedReason?: string; + + @plugins.smartdata.svDb() + public lastActivityAt: Date = new Date(); + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + /** + * Create a new session + */ + public static async createSession(data: { + userId: string; + userAgent: string; + ipAddress: string; + }): Promise { + const session = new Session(); + session.id = await Session.getNewId(); + session.userId = data.userId; + session.userAgent = data.userAgent; + session.ipAddress = data.ipAddress; + session.isValid = true; + session.lastActivityAt = new Date(); + session.createdAt = new Date(); + await session.save(); + return session; + } + + /** + * Find valid session by ID + */ + public static async findValidSession(sessionId: string): Promise { + const session = await Session.getInstance({ + id: sessionId, + isValid: true, + }); + + if (!session) return null; + + // Check if session is expired (7 days) + const maxAge = 7 * 24 * 60 * 60 * 1000; + if (Date.now() - session.createdAt.getTime() > maxAge) { + await session.invalidate('expired'); + return null; + } + + return session; + } + + /** + * Get all valid sessions for a user + */ + public static async getUserSessions(userId: string): Promise { + return await Session.getInstances({ + userId, + isValid: true, + }); + } + + /** + * Invalidate all sessions for a user + */ + public static async invalidateAllUserSessions( + userId: string, + reason: string = 'logout_all' + ): Promise { + const sessions = await Session.getUserSessions(userId); + for (const session of sessions) { + await session.invalidate(reason); + } + return sessions.length; + } + + /** + * Invalidate this session + */ + public async invalidate(reason: string): Promise { + this.isValid = false; + this.invalidatedAt = new Date(); + this.invalidatedReason = reason; + await this.save(); + } + + /** + * Update last activity + */ + public async touchActivity(): Promise { + this.lastActivityAt = new Date(); + await this.save(); + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + if (!this.id) { + this.id = await Session.getNewId(); + } + } +} diff --git a/ts/models/team.member.ts b/ts/models/team.member.ts new file mode 100644 index 0000000..ea1c9d1 --- /dev/null +++ b/ts/models/team.member.ts @@ -0,0 +1,97 @@ +/** + * TeamMember model - links users to teams with roles + */ + +import * as plugins from '../plugins.ts'; +import type { ITeamMember, TTeamRole } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class TeamMember + extends plugins.smartdata.SmartDataDbDoc + implements ITeamMember +{ + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public teamId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public userId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public role: TTeamRole = 'member'; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + /** + * Add a member to a team + */ + public static async addMember(data: { + teamId: string; + userId: string; + role: TTeamRole; + }): Promise { + // Check if member already exists + const existing = await TeamMember.getInstance({ + teamId: data.teamId, + userId: data.userId, + }); + + if (existing) { + throw new Error('User is already a member of this team'); + } + + const member = new TeamMember(); + member.id = await TeamMember.getNewId(); + member.teamId = data.teamId; + member.userId = data.userId; + member.role = data.role; + member.createdAt = new Date(); + await member.save(); + return member; + } + + /** + * Find membership for user in team + */ + public static async findMembership(teamId: string, userId: string): Promise { + return await TeamMember.getInstance({ + teamId, + userId, + }); + } + + /** + * Get all members of a team + */ + public static async getTeamMembers(teamId: string): Promise { + return await TeamMember.getInstances({ + teamId, + }); + } + + /** + * Get all teams a user belongs to + */ + public static async getUserTeams(userId: string): Promise { + return await TeamMember.getInstances({ + userId, + }); + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + if (!this.id) { + this.id = await TeamMember.getNewId(); + } + } +} diff --git a/ts/models/team.ts b/ts/models/team.ts new file mode 100644 index 0000000..4b75474 --- /dev/null +++ b/ts/models/team.ts @@ -0,0 +1,100 @@ +/** + * Team model for Stack.Gallery Registry + */ + +import * as plugins from '../plugins.ts'; +import type { ITeam } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class Team extends plugins.smartdata.SmartDataDbDoc implements ITeam { + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public organizationId: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + public name: string = ''; + + @plugins.smartdata.svDb() + public description?: string; + + @plugins.smartdata.svDb() + public isDefaultTeam: boolean = false; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + @plugins.smartdata.svDb() + public updatedAt: Date = new Date(); + + /** + * Create a new team + */ + public static async createTeam(data: { + organizationId: string; + name: string; + description?: string; + isDefaultTeam?: boolean; + }): Promise { + // Validate name + const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + if (!nameRegex.test(data.name.toLowerCase())) { + throw new Error('Team name must be lowercase alphanumeric with optional hyphens'); + } + + // Check for duplicate name in org + const existing = await Team.getInstance({ + organizationId: data.organizationId, + name: data.name.toLowerCase(), + }); + + if (existing) { + throw new Error('Team with this name already exists in the organization'); + } + + const team = new Team(); + team.id = await Team.getNewId(); + team.organizationId = data.organizationId; + team.name = data.name.toLowerCase(); + team.description = data.description; + team.isDefaultTeam = data.isDefaultTeam || false; + team.createdAt = new Date(); + team.updatedAt = new Date(); + await team.save(); + return team; + } + + /** + * Find team by name in organization + */ + public static async findByName(organizationId: string, name: string): Promise { + return await Team.getInstance({ + organizationId, + name: name.toLowerCase(), + }); + } + + /** + * Get all teams in an organization + */ + public static async getOrgTeams(organizationId: string): Promise { + return await Team.getInstances({ + organizationId, + }); + } + + /** + * Lifecycle hook + */ + public async beforeSave(): Promise { + this.updatedAt = new Date(); + if (!this.id) { + this.id = await Team.getNewId(); + } + } +} diff --git a/ts/models/user.ts b/ts/models/user.ts new file mode 100644 index 0000000..2900d18 --- /dev/null +++ b/ts/models/user.ts @@ -0,0 +1,115 @@ +/** + * User model for Stack.Gallery Registry + */ + +import * as plugins from '../plugins.ts'; +import type { IUser, TUserStatus } from '../interfaces/auth.interfaces.ts'; +import { getDb } from './db.ts'; + +@plugins.smartdata.Collection(() => getDb()) +export class User extends plugins.smartdata.SmartDataDbDoc implements IUser { + @plugins.smartdata.unI() + public id: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + @plugins.smartdata.index({ unique: true }) + public email: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + @plugins.smartdata.index({ unique: true }) + public username: string = ''; + + @plugins.smartdata.svDb() + public passwordHash: string = ''; + + @plugins.smartdata.svDb() + @plugins.smartdata.searchable() + public displayName: string = ''; + + @plugins.smartdata.svDb() + public avatarUrl?: string; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public status: TUserStatus = 'pending_verification'; + + @plugins.smartdata.svDb() + public emailVerified: boolean = false; + + @plugins.smartdata.svDb() + public mfaEnabled: boolean = false; + + @plugins.smartdata.svDb() + public mfaSecret?: string; + + @plugins.smartdata.svDb() + public lastLoginAt?: Date; + + @plugins.smartdata.svDb() + public lastLoginIp?: string; + + @plugins.smartdata.svDb() + public failedLoginAttempts: number = 0; + + @plugins.smartdata.svDb() + public lockedUntil?: Date; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public isPlatformAdmin: boolean = false; + + @plugins.smartdata.svDb() + @plugins.smartdata.index() + public createdAt: Date = new Date(); + + @plugins.smartdata.svDb() + public updatedAt: Date = new Date(); + + /** + * Create a new user instance + */ + public static async createUser(data: { + email: string; + username: string; + passwordHash: string; + displayName?: string; + }): Promise { + const user = new User(); + user.id = await User.getNewId(); + user.email = data.email.toLowerCase(); + user.username = data.username.toLowerCase(); + user.passwordHash = data.passwordHash; + user.displayName = data.displayName || data.username; + user.status = 'pending_verification'; + user.createdAt = new Date(); + user.updatedAt = new Date(); + await user.save(); + return user; + } + + /** + * Find user by email + */ + public static async findByEmail(email: string): Promise { + return await User.getInstance({ email: email.toLowerCase() }); + } + + /** + * Find user by username + */ + public static async findByUsername(username: string): Promise { + return await User.getInstance({ username: username.toLowerCase() }); + } + + /** + * Lifecycle hook: Update timestamps before save + */ + public async beforeSave(): Promise { + this.updatedAt = new Date(); + if (!this.id) { + this.id = await User.getNewId(); + } + } +} diff --git a/ts/plugins.ts b/ts/plugins.ts new file mode 100644 index 0000000..4dfd44a --- /dev/null +++ b/ts/plugins.ts @@ -0,0 +1,52 @@ +/** + * Centralized dependency imports + * All external modules should be imported here and accessed via plugins.* + */ + +// Push.rocks packages +import * as smartregistry from '@push.rocks/smartregistry'; +import * as smartdata from '@push.rocks/smartdata'; +import * as smartbucket from '@push.rocks/smartbucket'; +import * as smartlog from '@push.rocks/smartlog'; +import * as smartenv from '@push.rocks/smartenv'; +import * as smartpath from '@push.rocks/smartpath'; +import * as smartpromise from '@push.rocks/smartpromise'; +import * as smartstring from '@push.rocks/smartstring'; +import * as smartcrypto from '@push.rocks/smartcrypto'; +import * as smartjwt from '@push.rocks/smartjwt'; +import * as smartunique from '@push.rocks/smartunique'; +import * as smartdelay from '@push.rocks/smartdelay'; +import * as smartrx from '@push.rocks/smartrx'; +import * as smartcli from '@push.rocks/smartcli'; + +// tsclass types +import * as tsclass from '@tsclass/tsclass'; + +// Deno std library +import * as path from '@std/path'; +import * as fs from '@std/fs'; +import * as http from '@std/http'; + +export { + // Push.rocks + smartregistry, + smartdata, + smartbucket, + smartlog, + smartenv, + smartpath, + smartpromise, + smartstring, + smartcrypto, + smartjwt, + smartunique, + smartdelay, + smartrx, + smartcli, + // tsclass + tsclass, + // Deno std + path, + fs, + http, +}; diff --git a/ts/providers/auth.provider.ts b/ts/providers/auth.provider.ts new file mode 100644 index 0000000..0203fde --- /dev/null +++ b/ts/providers/auth.provider.ts @@ -0,0 +1,277 @@ +/** + * IAuthProvider implementation for smartregistry + * Integrates Stack.Gallery's auth system with smartregistry's protocol handlers + */ + +import * as plugins from '../plugins.ts'; +import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts'; +import { User } from '../models/user.ts'; +import { TokenService } from '../services/token.service.ts'; +import { PermissionService, type TAction } from '../services/permission.service.ts'; +import { AuditService } from '../services/audit.service.ts'; +import { AuthService } from '../services/auth.service.ts'; + +/** + * Request actor representing the authenticated entity making a request + */ +export interface IStackGalleryActor { + type: 'user' | 'api_token' | 'anonymous'; + userId?: string; + user?: User; + tokenId?: string; + ip?: string; + userAgent?: string; + protocols: TRegistryProtocol[]; + permissions: { + organizationId?: string; + repositoryId?: string; + canRead: boolean; + canWrite: boolean; + canDelete: boolean; + }; +} + +/** + * Auth provider that implements smartregistry's IAuthProvider interface + */ +export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProvider { + private tokenService: TokenService; + private permissionService: PermissionService; + private authService: AuthService; + + constructor() { + this.tokenService = new TokenService(); + this.permissionService = new PermissionService(); + this.authService = new AuthService(); + } + + /** + * Authenticate a request and return the actor + * Called by smartregistry for every incoming request + */ + public async authenticate(request: plugins.smartregistry.IAuthRequest): Promise { + const auditContext = AuditService.withContext({ + actorIp: request.ip, + actorUserAgent: request.userAgent, + }); + + // Extract auth credentials + const authHeader = request.headers?.['authorization'] || request.headers?.['Authorization']; + + // Try Bearer token (API token) + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.substring(7); + return await this.authenticateWithApiToken(token, request, auditContext); + } + + // Try Basic auth (for npm/other CLI tools) + if (authHeader?.startsWith('Basic ')) { + const credentials = authHeader.substring(6); + return await this.authenticateWithBasicAuth(credentials, request, auditContext); + } + + // Anonymous access + return this.createAnonymousActor(request); + } + + /** + * Check if actor has permission for the requested action + */ + public async authorize( + actor: plugins.smartregistry.IRequestActor, + request: plugins.smartregistry.IAuthorizationRequest + ): Promise { + const stackActor = actor as IStackGalleryActor; + + // Anonymous users can only read public packages + if (stackActor.type === 'anonymous') { + if (request.action === 'read' && request.isPublic) { + return { allowed: true }; + } + return { + allowed: false, + reason: 'Authentication required', + statusCode: 401, + }; + } + + // Check protocol access + if (!stackActor.protocols.includes(request.protocol as TRegistryProtocol) && + !stackActor.protocols.includes('*' as TRegistryProtocol)) { + return { + allowed: false, + reason: `Token does not have access to ${request.protocol} protocol`, + statusCode: 403, + }; + } + + // Map action to TAction + const action = this.mapAction(request.action); + + // Resolve permissions + const permissions = await this.permissionService.resolvePermissions({ + userId: stackActor.userId!, + organizationId: request.organizationId, + repositoryId: request.repositoryId, + protocol: request.protocol as TRegistryProtocol, + }); + + // Check permission + let allowed = false; + switch (action) { + case 'read': + allowed = permissions.canRead || (request.isPublic ?? false); + break; + case 'write': + allowed = permissions.canWrite; + break; + case 'delete': + allowed = permissions.canDelete; + break; + case 'admin': + allowed = permissions.canAdmin; + break; + } + + if (!allowed) { + return { + allowed: false, + reason: `Insufficient permissions for ${request.action} on ${request.resourceType}`, + statusCode: 403, + }; + } + + return { allowed: true }; + } + + /** + * Authenticate using API token + */ + private async authenticateWithApiToken( + rawToken: string, + request: plugins.smartregistry.IAuthRequest, + auditContext: AuditService + ): Promise { + const result = await this.tokenService.validateToken(rawToken, request.ip); + + if (!result.valid || !result.token || !result.user) { + await auditContext.logFailure( + 'TOKEN_USED', + 'api_token', + result.errorCode || 'UNKNOWN', + result.errorMessage || 'Token validation failed' + ); + + return this.createAnonymousActor(request); + } + + await auditContext.log('TOKEN_USED', 'api_token', { + resourceId: result.token.id, + success: true, + }); + + return { + type: 'api_token', + userId: result.user.id, + user: result.user, + tokenId: result.token.id, + ip: request.ip, + userAgent: request.userAgent, + protocols: result.token.protocols, + permissions: { + canRead: true, + canWrite: true, + canDelete: true, + }, + }; + } + + /** + * Authenticate using Basic auth (username:password or username:token) + */ + private async authenticateWithBasicAuth( + credentials: string, + request: plugins.smartregistry.IAuthRequest, + auditContext: AuditService + ): Promise { + try { + const decoded = atob(credentials); + const [username, password] = decoded.split(':'); + + // If password looks like an API token, try token auth + if (password?.startsWith('srg_')) { + return await this.authenticateWithApiToken(password, request, auditContext); + } + + // Otherwise try username/password (email/password) + const result = await this.authService.login(username, password, { + userAgent: request.userAgent, + ipAddress: request.ip, + }); + + if (!result.success || !result.user) { + return this.createAnonymousActor(request); + } + + return { + type: 'user', + userId: result.user.id, + user: result.user, + ip: request.ip, + userAgent: request.userAgent, + protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'], + permissions: { + canRead: true, + canWrite: true, + canDelete: true, + }, + }; + } catch { + return this.createAnonymousActor(request); + } + } + + /** + * Create anonymous actor + */ + private createAnonymousActor(request: plugins.smartregistry.IAuthRequest): IStackGalleryActor { + return { + type: 'anonymous', + ip: request.ip, + userAgent: request.userAgent, + protocols: [], + permissions: { + canRead: false, + canWrite: false, + canDelete: false, + }, + }; + } + + /** + * Map smartregistry action to our TAction type + */ + private mapAction(action: string): TAction { + switch (action) { + case 'read': + case 'pull': + case 'download': + case 'fetch': + return 'read'; + case 'write': + case 'push': + case 'publish': + case 'upload': + return 'write'; + case 'delete': + case 'unpublish': + case 'remove': + return 'delete'; + case 'admin': + case 'manage': + return 'admin'; + default: + return 'read'; + } + } +} diff --git a/ts/providers/index.ts b/ts/providers/index.ts new file mode 100644 index 0000000..169aecd --- /dev/null +++ b/ts/providers/index.ts @@ -0,0 +1,6 @@ +/** + * Provider exports + */ + +export { StackGalleryAuthProvider, type IStackGalleryActor } from './auth.provider.ts'; +export { StackGalleryStorageHooks, type IStorageConfig } from './storage.provider.ts'; diff --git a/ts/providers/storage.provider.ts b/ts/providers/storage.provider.ts new file mode 100644 index 0000000..54df03d --- /dev/null +++ b/ts/providers/storage.provider.ts @@ -0,0 +1,297 @@ +/** + * IStorageHooks implementation for smartregistry + * Integrates Stack.Gallery's storage with smartregistry + */ + +import * as plugins from '../plugins.ts'; +import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts'; +import { Package } from '../models/package.ts'; +import { Repository } from '../models/repository.ts'; +import { Organization } from '../models/organization.ts'; +import { AuditService } from '../services/audit.service.ts'; + +export interface IStorageConfig { + bucket: plugins.smartbucket.SmartBucket; + basePath: string; +} + +/** + * Storage hooks implementation that tracks packages in MongoDB + * and stores artifacts in S3 via smartbucket + */ +export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageHooks { + private config: IStorageConfig; + + constructor(config: IStorageConfig) { + this.config = config; + } + + /** + * Called before a package is stored + * Use this to validate, transform, or prepare for storage + */ + public async beforeStore(context: plugins.smartregistry.IStorageContext): Promise { + // Validate organization exists and has quota + const org = await Organization.findById(context.organizationId); + if (!org) { + throw new Error(`Organization not found: ${context.organizationId}`); + } + + // Check storage quota + const newSize = context.size || 0; + if (org.settings.quotas.maxStorageBytes > 0) { + if (org.usedStorageBytes + newSize > org.settings.quotas.maxStorageBytes) { + throw new Error('Organization storage quota exceeded'); + } + } + + // Validate repository exists + const repo = await Repository.findById(context.repositoryId); + if (!repo) { + throw new Error(`Repository not found: ${context.repositoryId}`); + } + + // Check repository protocol + if (!repo.protocols.includes(context.protocol as TRegistryProtocol)) { + throw new Error(`Repository does not support ${context.protocol} protocol`); + } + + return context; + } + + /** + * Called after a package is successfully stored + * Update database records and metrics + */ + public async afterStore(context: plugins.smartregistry.IStorageContext): Promise { + const protocol = context.protocol as TRegistryProtocol; + const packageId = Package.generateId(protocol, context.organizationName, context.packageName); + + // Get or create package record + let pkg = await Package.findById(packageId); + if (!pkg) { + pkg = new Package(); + pkg.id = packageId; + pkg.organizationId = context.organizationId; + pkg.repositoryId = context.repositoryId; + pkg.protocol = protocol; + pkg.name = context.packageName; + pkg.createdById = context.actorId || ''; + pkg.createdAt = new Date(); + } + + // Add version + pkg.addVersion({ + version: context.version, + publishedAt: new Date(), + publishedBy: context.actorId || '', + size: context.size || 0, + checksum: context.checksum || '', + checksumAlgorithm: context.checksumAlgorithm || 'sha256', + downloads: 0, + metadata: context.metadata || {}, + }); + + // Update dist tags if provided + if (context.tags) { + for (const [tag, version] of Object.entries(context.tags)) { + pkg.distTags[tag] = version; + } + } + + // Set latest tag if not set + if (!pkg.distTags['latest']) { + pkg.distTags['latest'] = context.version; + } + + await pkg.save(); + + // Update organization storage usage + const org = await Organization.findById(context.organizationId); + if (org) { + org.usedStorageBytes += context.size || 0; + await org.save(); + } + + // Audit log + await AuditService.withContext({ + actorId: context.actorId, + actorType: context.actorId ? 'user' : 'anonymous', + organizationId: context.organizationId, + repositoryId: context.repositoryId, + }).logPackagePublished( + packageId, + context.packageName, + context.version, + context.organizationId, + context.repositoryId + ); + } + + /** + * Called before a package is fetched + */ + public async beforeFetch(context: plugins.smartregistry.IFetchContext): Promise { + return context; + } + + /** + * Called after a package is fetched + * Update download metrics + */ + public async afterFetch(context: plugins.smartregistry.IFetchContext): Promise { + const protocol = context.protocol as TRegistryProtocol; + const packageId = Package.generateId(protocol, context.organizationName, context.packageName); + + const pkg = await Package.findById(packageId); + if (pkg) { + await pkg.incrementDownloads(context.version); + } + + // Audit log for authenticated users + if (context.actorId) { + await AuditService.withContext({ + actorId: context.actorId, + actorType: 'user', + organizationId: context.organizationId, + repositoryId: context.repositoryId, + }).logPackageDownloaded( + packageId, + context.packageName, + context.version || 'latest', + context.organizationId, + context.repositoryId + ); + } + } + + /** + * Called before a package is deleted + */ + public async beforeDelete(context: plugins.smartregistry.IDeleteContext): Promise { + return context; + } + + /** + * Called after a package is deleted + */ + public async afterDelete(context: plugins.smartregistry.IDeleteContext): Promise { + const protocol = context.protocol as TRegistryProtocol; + const packageId = Package.generateId(protocol, context.organizationName, context.packageName); + + const pkg = await Package.findById(packageId); + if (!pkg) return; + + if (context.version) { + // Delete specific version + const version = pkg.versions[context.version]; + if (version) { + const sizeReduction = version.size; + delete pkg.versions[context.version]; + pkg.storageBytes -= sizeReduction; + + // Update dist tags + for (const [tag, ver] of Object.entries(pkg.distTags)) { + if (ver === context.version) { + delete pkg.distTags[tag]; + } + } + + // If no versions left, delete the package + if (Object.keys(pkg.versions).length === 0) { + await pkg.delete(); + } else { + await pkg.save(); + } + + // Update org storage + const org = await Organization.findById(context.organizationId); + if (org) { + org.usedStorageBytes -= sizeReduction; + await org.save(); + } + } + } else { + // Delete entire package + const sizeReduction = pkg.storageBytes; + await pkg.delete(); + + // Update org storage + const org = await Organization.findById(context.organizationId); + if (org) { + org.usedStorageBytes -= sizeReduction; + await org.save(); + } + } + + // Audit log + await AuditService.withContext({ + actorId: context.actorId, + actorType: context.actorId ? 'user' : 'system', + organizationId: context.organizationId, + repositoryId: context.repositoryId, + }).log('PACKAGE_DELETED', 'package', { + resourceId: packageId, + resourceName: context.packageName, + metadata: { version: context.version }, + success: true, + }); + } + + /** + * Get the S3 path for a package artifact + */ + public getArtifactPath( + protocol: string, + organizationName: string, + packageName: string, + version: string, + filename: string + ): string { + return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`; + } + + /** + * Store artifact in S3 + */ + public async storeArtifact( + path: string, + data: Uint8Array, + contentType?: string + ): Promise { + const bucket = await this.config.bucket.getBucket(); + await bucket.fastPut({ + path, + contents: Buffer.from(data), + contentType: contentType || 'application/octet-stream', + }); + return path; + } + + /** + * Fetch artifact from S3 + */ + public async fetchArtifact(path: string): Promise { + try { + const bucket = await this.config.bucket.getBucket(); + const file = await bucket.fastGet({ path }); + if (!file) return null; + return new Uint8Array(file.contents); + } catch { + return null; + } + } + + /** + * Delete artifact from S3 + */ + public async deleteArtifact(path: string): Promise { + try { + const bucket = await this.config.bucket.getBucket(); + await bucket.fastDelete({ path }); + return true; + } catch { + return false; + } + } +} diff --git a/ts/registry.ts b/ts/registry.ts new file mode 100644 index 0000000..4bfbc80 --- /dev/null +++ b/ts/registry.ts @@ -0,0 +1,276 @@ +/** + * StackGalleryRegistry - Main registry class + * Integrates smartregistry with Stack.Gallery's auth, storage, and database + */ + +import * as plugins from './plugins.ts'; +import { initDb, closeDb, isDbConnected } from './models/db.ts'; +import { StackGalleryAuthProvider } from './providers/auth.provider.ts'; +import { StackGalleryStorageHooks } from './providers/storage.provider.ts'; +import { ApiRouter } from './api/router.ts'; + +export interface IRegistryConfig { + // MongoDB configuration + mongoUrl: string; + mongoDb: string; + + // S3 configuration + s3Endpoint: string; + s3AccessKey: string; + s3SecretKey: string; + s3Bucket: string; + s3Region?: string; + + // Server configuration + host?: string; + port?: number; + + // Registry settings + storagePath?: string; + enableUpstreamCache?: boolean; + upstreamCacheExpiry?: number; // hours + + // JWT configuration + jwtSecret?: string; +} + +export class StackGalleryRegistry { + private config: IRegistryConfig; + private smartBucket: plugins.smartbucket.SmartBucket | null = null; + private smartRegistry: plugins.smartregistry.SmartRegistry | null = null; + private authProvider: StackGalleryAuthProvider | null = null; + private storageHooks: StackGalleryStorageHooks | null = null; + private apiRouter: ApiRouter | null = null; + private isInitialized = false; + + constructor(config: IRegistryConfig) { + this.config = { + host: '0.0.0.0', + port: 3000, + storagePath: 'packages', + enableUpstreamCache: true, + upstreamCacheExpiry: 24, + ...config, + }; + } + + /** + * Initialize the registry + */ + public async init(): Promise { + if (this.isInitialized) return; + + console.log('[StackGalleryRegistry] Initializing...'); + + // Initialize MongoDB + console.log('[StackGalleryRegistry] Connecting to MongoDB...'); + await initDb(this.config.mongoUrl, this.config.mongoDb); + console.log('[StackGalleryRegistry] MongoDB connected'); + + // Initialize S3/SmartBucket + console.log('[StackGalleryRegistry] Initializing S3 storage...'); + this.smartBucket = new plugins.smartbucket.SmartBucket({ + accessKey: this.config.s3AccessKey, + accessSecret: this.config.s3SecretKey, + endpoint: this.config.s3Endpoint, + bucketName: this.config.s3Bucket, + }); + console.log('[StackGalleryRegistry] S3 storage initialized'); + + // Initialize auth provider + this.authProvider = new StackGalleryAuthProvider(); + + // Initialize storage hooks + this.storageHooks = new StackGalleryStorageHooks({ + bucket: this.smartBucket, + basePath: this.config.storagePath!, + }); + + // Initialize smartregistry + console.log('[StackGalleryRegistry] Initializing smartregistry...'); + this.smartRegistry = new plugins.smartregistry.SmartRegistry({ + authProvider: this.authProvider, + storageHooks: this.storageHooks, + storage: { + type: 's3', + bucket: this.smartBucket, + basePath: this.config.storagePath, + }, + upstreamCache: this.config.enableUpstreamCache + ? { + enabled: true, + expiryHours: this.config.upstreamCacheExpiry, + } + : undefined, + }); + console.log('[StackGalleryRegistry] smartregistry initialized'); + + // Initialize API router + console.log('[StackGalleryRegistry] Initializing API router...'); + this.apiRouter = new ApiRouter(); + console.log('[StackGalleryRegistry] API router initialized'); + + this.isInitialized = true; + console.log('[StackGalleryRegistry] Initialization complete'); + } + + /** + * Start the HTTP server + */ + public async start(): Promise { + if (!this.isInitialized) { + await this.init(); + } + + const port = this.config.port!; + const host = this.config.host!; + + console.log(`[StackGalleryRegistry] Starting server on ${host}:${port}...`); + + Deno.serve( + { port, hostname: host }, + async (request: Request): Promise => { + return await this.handleRequest(request); + } + ); + + console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`); + } + + /** + * Handle incoming HTTP request + */ + private async handleRequest(request: Request): Promise { + const url = new URL(request.url); + const path = url.pathname; + + // Health check + if (path === '/health' || path === '/healthz') { + return this.healthCheck(); + } + + // API endpoints (handled by REST API layer) + if (path.startsWith('/api/')) { + return await this.handleApiRequest(request); + } + + // Registry protocol endpoints + // NPM: /-/..., /@scope/package, /package + // OCI: /v2/... + // Maven: /maven2/... + // PyPI: /simple/..., /pypi/... + // Cargo: /api/v1/crates/... + // Composer: /packages.json, /p/... + // RubyGems: /api/v1/gems/..., /gems/... + + if (this.smartRegistry) { + try { + return await this.smartRegistry.handleRequest(request); + } catch (error) { + console.error('[StackGalleryRegistry] Request error:', error); + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + } + + return new Response('Not Found', { status: 404 }); + } + + /** + * Handle API requests + */ + private async handleApiRequest(request: Request): Promise { + if (!this.apiRouter) { + return new Response( + JSON.stringify({ error: 'API router not initialized' }), + { + status: 503, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return await this.apiRouter.handle(request); + } + + /** + * Health check endpoint + */ + private healthCheck(): Response { + const healthy = this.isInitialized && isDbConnected(); + + const status = { + status: healthy ? 'healthy' : 'unhealthy', + timestamp: new Date().toISOString(), + services: { + mongodb: isDbConnected() ? 'connected' : 'disconnected', + s3: this.smartBucket ? 'initialized' : 'not initialized', + registry: this.smartRegistry ? 'initialized' : 'not initialized', + }, + }; + + return new Response(JSON.stringify(status), { + status: healthy ? 200 : 503, + headers: { 'Content-Type': 'application/json' }, + }); + } + + /** + * Stop the registry + */ + public async stop(): Promise { + console.log('[StackGalleryRegistry] Shutting down...'); + await closeDb(); + this.isInitialized = false; + console.log('[StackGalleryRegistry] Shutdown complete'); + } + + /** + * Get the smartregistry instance + */ + public getSmartRegistry(): plugins.smartregistry.SmartRegistry | null { + return this.smartRegistry; + } + + /** + * Get the smartbucket instance + */ + public getSmartBucket(): plugins.smartbucket.SmartBucket | null { + return this.smartBucket; + } + + /** + * Check if registry is initialized + */ + public getIsInitialized(): boolean { + return this.isInitialized; + } +} + +/** + * Create registry from environment variables + */ +export function createRegistryFromEnv(): StackGalleryRegistry { + const config: IRegistryConfig = { + mongoUrl: Deno.env.get('MONGODB_URL') || 'mongodb://localhost:27017', + mongoDb: Deno.env.get('MONGODB_DB') || 'stackgallery', + s3Endpoint: Deno.env.get('S3_ENDPOINT') || 'http://localhost:9000', + s3AccessKey: Deno.env.get('S3_ACCESS_KEY') || 'minioadmin', + s3SecretKey: Deno.env.get('S3_SECRET_KEY') || 'minioadmin', + s3Bucket: Deno.env.get('S3_BUCKET') || 'registry', + s3Region: Deno.env.get('S3_REGION'), + host: Deno.env.get('HOST') || '0.0.0.0', + port: parseInt(Deno.env.get('PORT') || '3000', 10), + storagePath: Deno.env.get('STORAGE_PATH') || 'packages', + enableUpstreamCache: Deno.env.get('ENABLE_UPSTREAM_CACHE') !== 'false', + upstreamCacheExpiry: parseInt(Deno.env.get('UPSTREAM_CACHE_EXPIRY') || '24', 10), + jwtSecret: Deno.env.get('JWT_SECRET'), + }; + + return new StackGalleryRegistry(config); +} diff --git a/ts/services/audit.service.ts b/ts/services/audit.service.ts new file mode 100644 index 0000000..2f1faf6 --- /dev/null +++ b/ts/services/audit.service.ts @@ -0,0 +1,197 @@ +/** + * AuditService - Centralized audit logging + */ + +import type { TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts'; +import { AuditLog } from '../models/index.ts'; + +export interface IAuditContext { + actorId?: string; + actorType?: 'user' | 'api_token' | 'system' | 'anonymous'; + actorTokenId?: string; + actorIp?: string; + actorUserAgent?: string; + organizationId?: string; + repositoryId?: string; +} + +export class AuditService { + private context: IAuditContext; + + constructor(context: IAuditContext = {}) { + this.context = context; + } + + /** + * Create a new audit service with context + */ + public static withContext(context: IAuditContext): AuditService { + return new AuditService(context); + } + + /** + * Log an audit event + */ + public async log( + action: TAuditAction, + resourceType: TAuditResourceType, + options: { + resourceId?: string; + resourceName?: string; + organizationId?: string; + repositoryId?: string; + metadata?: Record; + success?: boolean; + errorCode?: string; + errorMessage?: string; + durationMs?: number; + } = {} + ): Promise { + return await AuditLog.log({ + actorId: this.context.actorId, + actorType: this.context.actorType, + actorTokenId: this.context.actorTokenId, + actorIp: this.context.actorIp, + actorUserAgent: this.context.actorUserAgent, + action, + resourceType, + resourceId: options.resourceId, + resourceName: options.resourceName, + organizationId: options.organizationId || this.context.organizationId, + repositoryId: options.repositoryId || this.context.repositoryId, + metadata: options.metadata, + success: options.success, + errorCode: options.errorCode, + errorMessage: options.errorMessage, + durationMs: options.durationMs, + }); + } + + /** + * Log a successful action + */ + public async logSuccess( + action: TAuditAction, + resourceType: TAuditResourceType, + resourceId?: string, + resourceName?: string, + metadata?: Record + ): Promise { + return await this.log(action, resourceType, { + resourceId, + resourceName, + metadata, + success: true, + }); + } + + /** + * Log a failed action + */ + public async logFailure( + action: TAuditAction, + resourceType: TAuditResourceType, + errorCode: string, + errorMessage: string, + resourceId?: string, + metadata?: Record + ): Promise { + return await this.log(action, resourceType, { + resourceId, + metadata, + success: false, + errorCode, + errorMessage, + }); + } + + // Convenience methods for common actions + + public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise { + if (success) { + return await this.logSuccess('USER_LOGIN', 'user', userId); + } + return await this.logFailure('USER_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId); + } + + public async logUserLogout(userId: string): Promise { + return await this.logSuccess('USER_LOGOUT', 'user', userId); + } + + public async logTokenCreated(tokenId: string, tokenName: string): Promise { + return await this.logSuccess('TOKEN_CREATED', 'api_token', tokenId, tokenName); + } + + public async logTokenRevoked(tokenId: string, tokenName: string): Promise { + return await this.logSuccess('TOKEN_REVOKED', 'api_token', tokenId, tokenName); + } + + public async logPackagePublished( + packageId: string, + packageName: string, + version: string, + organizationId: string, + repositoryId: string + ): Promise { + return await this.log('PACKAGE_PUBLISHED', 'package', { + resourceId: packageId, + resourceName: packageName, + organizationId, + repositoryId, + metadata: { version }, + success: true, + }); + } + + public async logPackageDownloaded( + packageId: string, + packageName: string, + version: string, + organizationId: string, + repositoryId: string + ): Promise { + return await this.log('PACKAGE_DOWNLOADED', 'package', { + resourceId: packageId, + resourceName: packageName, + organizationId, + repositoryId, + metadata: { version }, + success: true, + }); + } + + public async logOrganizationCreated(orgId: string, orgName: string): Promise { + return await this.logSuccess('ORGANIZATION_CREATED', 'organization', orgId, orgName); + } + + public async logRepositoryCreated( + repoId: string, + repoName: string, + organizationId: string + ): Promise { + return await this.log('REPOSITORY_CREATED', 'repository', { + resourceId: repoId, + resourceName: repoName, + organizationId, + success: true, + }); + } + + public async logPermissionChanged( + resourceType: TAuditResourceType, + resourceId: string, + targetUserId: string, + oldRole: string | null, + newRole: string | null + ): Promise { + return await this.log('PERMISSION_CHANGED', resourceType, { + resourceId, + metadata: { + targetUserId, + oldRole, + newRole, + }, + success: true, + }); + } +} diff --git a/ts/services/auth.service.ts b/ts/services/auth.service.ts new file mode 100644 index 0000000..4ad289e --- /dev/null +++ b/ts/services/auth.service.ts @@ -0,0 +1,405 @@ +/** + * AuthService - JWT-based authentication for UI sessions + */ + +import * as plugins from '../plugins.ts'; +import { User, Session } from '../models/index.ts'; +import { AuditService } from './audit.service.ts'; + +export interface IJwtPayload { + sub: string; // User ID + email: string; + sessionId: string; + type: 'access' | 'refresh'; + iat: number; + exp: number; +} + +export interface IAuthResult { + success: boolean; + user?: User; + accessToken?: string; + refreshToken?: string; + sessionId?: string; + errorCode?: string; + errorMessage?: string; +} + +export interface IAuthConfig { + jwtSecret: string; + accessTokenExpiresIn: number; // seconds (default: 15 minutes) + refreshTokenExpiresIn: number; // seconds (default: 7 days) + issuer: string; +} + +export class AuthService { + private config: IAuthConfig; + private auditService: AuditService; + + constructor(config: Partial = {}) { + this.config = { + jwtSecret: config.jwtSecret || Deno.env.get('JWT_SECRET') || 'change-me-in-production', + accessTokenExpiresIn: config.accessTokenExpiresIn || 15 * 60, // 15 minutes + refreshTokenExpiresIn: config.refreshTokenExpiresIn || 7 * 24 * 60 * 60, // 7 days + issuer: config.issuer || 'stack.gallery', + }; + this.auditService = new AuditService({ actorType: 'system' }); + } + + /** + * Login with email and password + */ + public async login( + email: string, + password: string, + options: { userAgent?: string; ipAddress?: string } = {} + ): Promise { + const auditContext = AuditService.withContext({ + actorIp: options.ipAddress, + actorUserAgent: options.userAgent, + actorType: 'anonymous', + }); + + // Find user by email + const user = await User.findByEmail(email); + if (!user) { + await auditContext.logUserLogin('', false, 'User not found'); + return { + success: false, + errorCode: 'INVALID_CREDENTIALS', + errorMessage: 'Invalid email or password', + }; + } + + // Verify password + const isValid = await user.verifyPassword(password); + if (!isValid) { + await auditContext.logUserLogin(user.id, false, 'Invalid password'); + return { + success: false, + errorCode: 'INVALID_CREDENTIALS', + errorMessage: 'Invalid email or password', + }; + } + + // Check if user is active + if (!user.isActive) { + await auditContext.logUserLogin(user.id, false, 'Account inactive'); + return { + success: false, + errorCode: 'ACCOUNT_INACTIVE', + errorMessage: 'Account is inactive', + }; + } + + // Create session + const session = await Session.createSession({ + userId: user.id, + userAgent: options.userAgent || '', + ipAddress: options.ipAddress || '', + }); + + // Generate tokens + const accessToken = await this.generateAccessToken(user, session.id); + const refreshToken = await this.generateRefreshToken(user, session.id); + + // Update user last login + user.lastLoginAt = new Date(); + await user.save(); + + // Audit log + await AuditService.withContext({ + actorId: user.id, + actorType: 'user', + actorIp: options.ipAddress, + actorUserAgent: options.userAgent, + }).logUserLogin(user.id, true); + + return { + success: true, + user, + accessToken, + refreshToken, + sessionId: session.id, + }; + } + + /** + * Refresh access token using refresh token + */ + public async refresh(refreshToken: string): Promise { + // Verify refresh token + const payload = await this.verifyToken(refreshToken); + if (!payload) { + return { + success: false, + errorCode: 'INVALID_TOKEN', + errorMessage: 'Invalid refresh token', + }; + } + + if (payload.type !== 'refresh') { + return { + success: false, + errorCode: 'INVALID_TOKEN_TYPE', + errorMessage: 'Not a refresh token', + }; + } + + // Validate session + const session = await Session.findValidSession(payload.sessionId); + if (!session) { + return { + success: false, + errorCode: 'SESSION_INVALID', + errorMessage: 'Session is invalid or expired', + }; + } + + // Get user + const user = await User.findById(payload.sub); + if (!user || !user.isActive) { + return { + success: false, + errorCode: 'USER_INVALID', + errorMessage: 'User not found or inactive', + }; + } + + // Update session activity + await session.touchActivity(); + + // Generate new access token + const accessToken = await this.generateAccessToken(user, session.id); + + return { + success: true, + user, + accessToken, + sessionId: session.id, + }; + } + + /** + * Logout - invalidate session + */ + public async logout( + sessionId: string, + options: { userId?: string; ipAddress?: string } = {} + ): Promise { + const session = await Session.findValidSession(sessionId); + if (!session) return false; + + await session.invalidate('logout'); + + if (options.userId) { + await AuditService.withContext({ + actorId: options.userId, + actorType: 'user', + actorIp: options.ipAddress, + }).logUserLogout(options.userId); + } + + return true; + } + + /** + * Logout all sessions for a user + */ + public async logoutAll( + userId: string, + options: { ipAddress?: string } = {} + ): Promise { + const count = await Session.invalidateAllUserSessions(userId, 'logout_all'); + + await AuditService.withContext({ + actorId: userId, + actorType: 'user', + actorIp: options.ipAddress, + }).log('USER_LOGOUT', 'user', { + resourceId: userId, + metadata: { sessionsInvalidated: count }, + success: true, + }); + + return count; + } + + /** + * Validate access token and return user + */ + public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> { + const payload = await this.verifyToken(accessToken); + if (!payload || payload.type !== 'access') return null; + + // Validate session is still valid + const session = await Session.findValidSession(payload.sessionId); + if (!session) return null; + + const user = await User.findById(payload.sub); + if (!user || !user.isActive) return null; + + return { user, sessionId: payload.sessionId }; + } + + /** + * Generate access token + */ + private async generateAccessToken(user: User, sessionId: string): Promise { + const now = Math.floor(Date.now() / 1000); + const payload: IJwtPayload = { + sub: user.id, + email: user.email, + sessionId, + type: 'access', + iat: now, + exp: now + this.config.accessTokenExpiresIn, + }; + + return await this.signToken(payload); + } + + /** + * Generate refresh token + */ + private async generateRefreshToken(user: User, sessionId: string): Promise { + const now = Math.floor(Date.now() / 1000); + const payload: IJwtPayload = { + sub: user.id, + email: user.email, + sessionId, + type: 'refresh', + iat: now, + exp: now + this.config.refreshTokenExpiresIn, + }; + + return await this.signToken(payload); + } + + /** + * Sign a JWT token + */ + private async signToken(payload: IJwtPayload): Promise { + const header = { alg: 'HS256', typ: 'JWT' }; + + const encodedHeader = this.base64UrlEncode(JSON.stringify(header)); + const encodedPayload = this.base64UrlEncode(JSON.stringify(payload)); + + const data = `${encodedHeader}.${encodedPayload}`; + const signature = await this.hmacSign(data); + + return `${data}.${signature}`; + } + + /** + * Verify and decode a JWT token + */ + private async verifyToken(token: string): Promise { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const [encodedHeader, encodedPayload, signature] = parts; + const data = `${encodedHeader}.${encodedPayload}`; + + // Verify signature + const expectedSignature = await this.hmacSign(data); + if (signature !== expectedSignature) return null; + + // Decode payload + const payload: IJwtPayload = JSON.parse(this.base64UrlDecode(encodedPayload)); + + // Check expiration + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) return null; + + return payload; + } catch { + return null; + } + } + + /** + * HMAC-SHA256 sign + */ + private async hmacSign(data: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(this.config.jwtSecret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + return this.base64UrlEncode(String.fromCharCode(...new Uint8Array(signature))); + } + + /** + * Base64 URL encode + */ + private base64UrlEncode(str: string): string { + const base64 = btoa(str); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + /** + * Base64 URL decode + */ + private base64UrlDecode(str: string): string { + let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4) { + base64 += '='; + } + return atob(base64); + } + + /** + * Hash a password using bcrypt-like approach with Web Crypto + * Note: In production, use a proper bcrypt library + */ + public static async hashPassword(password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const saltHex = Array.from(salt) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + const encoder = new TextEncoder(); + const data = encoder.encode(saltHex + password); + + // Multiple rounds of hashing for security + let hash = data; + for (let i = 0; i < 10000; i++) { + hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash)); + } + + const hashHex = Array.from(hash) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return `${saltHex}:${hashHex}`; + } + + /** + * Verify a password against a hash + */ + public static async verifyPassword(password: string, storedHash: string): Promise { + const [saltHex, expectedHash] = storedHash.split(':'); + if (!saltHex || !expectedHash) return false; + + const encoder = new TextEncoder(); + const data = encoder.encode(saltHex + password); + + let hash = data; + for (let i = 0; i < 10000; i++) { + hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash)); + } + + const hashHex = Array.from(hash) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return hashHex === expectedHash; + } +} diff --git a/ts/services/index.ts b/ts/services/index.ts new file mode 100644 index 0000000..290917d --- /dev/null +++ b/ts/services/index.ts @@ -0,0 +1,22 @@ +/** + * Service exports + */ + +export { AuditService, type IAuditContext } from './audit.service.ts'; +export { + TokenService, + type ICreateTokenOptions, + type ITokenValidationResult, +} from './token.service.ts'; +export { + PermissionService, + type TAction, + type IPermissionContext, + type IResolvedPermissions, +} from './permission.service.ts'; +export { + AuthService, + type IJwtPayload, + type IAuthResult, + type IAuthConfig, +} from './auth.service.ts'; diff --git a/ts/services/permission.service.ts b/ts/services/permission.service.ts new file mode 100644 index 0000000..4722fba --- /dev/null +++ b/ts/services/permission.service.ts @@ -0,0 +1,307 @@ +/** + * PermissionService - RBAC resolution across org → team → repo hierarchy + */ + +import type { + TOrganizationRole, + TTeamRole, + TRepositoryRole, + TRegistryProtocol, +} from '../interfaces/auth.interfaces.ts'; +import { + User, + Organization, + OrganizationMember, + Team, + TeamMember, + Repository, + RepositoryPermission, +} from '../models/index.ts'; + +export type TAction = 'read' | 'write' | 'delete' | 'admin'; + +export interface IPermissionContext { + userId: string; + organizationId?: string; + repositoryId?: string; + protocol?: TRegistryProtocol; +} + +export interface IResolvedPermissions { + canRead: boolean; + canWrite: boolean; + canDelete: boolean; + canAdmin: boolean; + effectiveRole: TRepositoryRole | null; + organizationRole: TOrganizationRole | null; + teamRoles: Array<{ teamId: string; role: TTeamRole }>; + repositoryRole: TRepositoryRole | null; +} + +export class PermissionService { + /** + * Resolve all permissions for a user in a specific context + */ + public async resolvePermissions(context: IPermissionContext): Promise { + const result: IResolvedPermissions = { + canRead: false, + canWrite: false, + canDelete: false, + canAdmin: false, + effectiveRole: null, + organizationRole: null, + teamRoles: [], + repositoryRole: null, + }; + + // Get user + const user = await User.findById(context.userId); + if (!user || !user.isActive) return result; + + // System admins have full access + if (user.isSystemAdmin) { + result.canRead = true; + result.canWrite = true; + result.canDelete = true; + result.canAdmin = true; + result.effectiveRole = 'admin'; + return result; + } + + if (!context.organizationId) return result; + + // Get organization membership + const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId); + if (orgMember) { + result.organizationRole = orgMember.role; + + // Organization owners have full access to everything in the org + if (orgMember.role === 'owner') { + result.canRead = true; + result.canWrite = true; + result.canDelete = true; + result.canAdmin = true; + result.effectiveRole = 'admin'; + return result; + } + + // Organization admins have admin access to all repos + if (orgMember.role === 'admin') { + result.canRead = true; + result.canWrite = true; + result.canDelete = true; + result.canAdmin = true; + result.effectiveRole = 'admin'; + return result; + } + } + + // If no repository specified, check org-level permissions + if (!context.repositoryId) { + if (orgMember) { + result.canRead = true; // Members can read org info + result.effectiveRole = 'reader'; + } + return result; + } + + // Get repository + const repository = await Repository.findById(context.repositoryId); + if (!repository) return result; + + // Check if repository is public + if (repository.isPublic) { + result.canRead = true; + } + + // Get team memberships that grant access to this repository + if (orgMember) { + const teams = await Team.getOrgTeams(context.organizationId); + for (const team of teams) { + const teamMember = await TeamMember.findMembership(team.id, context.userId); + if (teamMember) { + result.teamRoles.push({ teamId: team.id, role: teamMember.role }); + + // Check if team has access to this repository + if (team.repositoryIds.includes(context.repositoryId)) { + // Team maintainers get maintainer access to repos + if (teamMember.role === 'maintainer') { + this.applyRole(result, 'maintainer'); + } else { + // Team members get developer access + this.applyRole(result, 'developer'); + } + } + } + } + } + + // Get direct repository permission (highest priority) + const repoPerm = await RepositoryPermission.findPermission(context.repositoryId, context.userId); + if (repoPerm) { + result.repositoryRole = repoPerm.role; + this.applyRole(result, repoPerm.role); + } + + return result; + } + + /** + * Check if user can perform a specific action + */ + public async checkPermission( + context: IPermissionContext, + action: TAction + ): Promise { + const permissions = await this.resolvePermissions(context); + + switch (action) { + case 'read': + return permissions.canRead; + case 'write': + return permissions.canWrite; + case 'delete': + return permissions.canDelete; + case 'admin': + return permissions.canAdmin; + default: + return false; + } + } + + /** + * Check if user can access a package + */ + public async canAccessPackage( + userId: string, + organizationId: string, + repositoryId: string, + action: 'read' | 'write' | 'delete' + ): Promise { + return await this.checkPermission( + { userId, organizationId, repositoryId }, + action + ); + } + + /** + * Check if user can manage organization + */ + public async canManageOrganization(userId: string, organizationId: string): Promise { + const user = await User.findById(userId); + if (!user || !user.isActive) return false; + if (user.isSystemAdmin) return true; + + const orgMember = await OrganizationMember.findMembership(organizationId, userId); + return orgMember?.role === 'owner' || orgMember?.role === 'admin'; + } + + /** + * Check if user can manage repository + */ + public async canManageRepository( + userId: string, + organizationId: string, + repositoryId: string + ): Promise { + const permissions = await this.resolvePermissions({ + userId, + organizationId, + repositoryId, + }); + return permissions.canAdmin; + } + + /** + * Get all repositories a user can access in an organization + */ + public async getAccessibleRepositories( + userId: string, + organizationId: string + ): Promise { + const user = await User.findById(userId); + if (!user || !user.isActive) return []; + + // System admins and org owners/admins can access all repos + if (user.isSystemAdmin) { + return await Repository.getOrgRepositories(organizationId); + } + + const orgMember = await OrganizationMember.findMembership(organizationId, userId); + if (orgMember?.role === 'owner' || orgMember?.role === 'admin') { + return await Repository.getOrgRepositories(organizationId); + } + + const allRepos = await Repository.getOrgRepositories(organizationId); + const accessibleRepos: Repository[] = []; + + for (const repo of allRepos) { + // Public repos are always accessible + if (repo.isPublic) { + accessibleRepos.push(repo); + continue; + } + + // Check direct permission + const directPerm = await RepositoryPermission.findPermission(repo.id, userId); + if (directPerm) { + accessibleRepos.push(repo); + continue; + } + + // Check team access + const teams = await Team.getOrgTeams(organizationId); + for (const team of teams) { + if (team.repositoryIds.includes(repo.id)) { + const teamMember = await TeamMember.findMembership(team.id, userId); + if (teamMember) { + accessibleRepos.push(repo); + break; + } + } + } + } + + return accessibleRepos; + } + + /** + * Apply a role's permissions to the result + */ + private applyRole(result: IResolvedPermissions, role: TRepositoryRole): void { + const roleHierarchy: Record = { + reader: 1, + developer: 2, + maintainer: 3, + admin: 4, + }; + + const currentLevel = result.effectiveRole ? roleHierarchy[result.effectiveRole] : 0; + const newLevel = roleHierarchy[role]; + + if (newLevel > currentLevel) { + result.effectiveRole = role; + } + + switch (role) { + case 'admin': + result.canRead = true; + result.canWrite = true; + result.canDelete = true; + result.canAdmin = true; + break; + case 'maintainer': + result.canRead = true; + result.canWrite = true; + result.canDelete = true; + break; + case 'developer': + result.canRead = true; + result.canWrite = true; + break; + case 'reader': + result.canRead = true; + break; + } + } +} diff --git a/ts/services/token.service.ts b/ts/services/token.service.ts new file mode 100644 index 0000000..55831cc --- /dev/null +++ b/ts/services/token.service.ts @@ -0,0 +1,209 @@ +/** + * TokenService - API token management with secure hashing + */ + +import * as plugins from '../plugins.ts'; +import type { ITokenScope, TRegistryProtocol } from '../interfaces/auth.interfaces.ts'; +import { ApiToken, User } from '../models/index.ts'; +import { AuditService } from './audit.service.ts'; + +export interface ICreateTokenOptions { + userId: string; + name: string; + protocols: TRegistryProtocol[]; + scopes: ITokenScope[]; + expiresInDays?: number; + createdIp?: string; +} + +export interface ITokenValidationResult { + valid: boolean; + token?: ApiToken; + user?: User; + errorCode?: string; + errorMessage?: string; +} + +export class TokenService { + private auditService: AuditService; + + constructor(auditService?: AuditService) { + this.auditService = auditService || new AuditService({ actorType: 'system' }); + } + + /** + * Generate a new API token + * Returns the raw token (only shown once) and the saved token record + */ + public async createToken(options: ICreateTokenOptions): Promise<{ rawToken: string; token: ApiToken }> { + // Generate secure random token: srg_{64 hex chars} + const randomBytes = new Uint8Array(32); + crypto.getRandomValues(randomBytes); + const hexToken = Array.from(randomBytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + const rawToken = `srg_${hexToken}`; + + // Hash the token for storage + const tokenHash = await this.hashToken(rawToken); + const tokenPrefix = rawToken.substring(0, 12); // "srg_" + first 8 hex chars + + // Create token record + const token = new ApiToken(); + token.id = await ApiToken.getNewId(); + token.userId = options.userId; + token.name = options.name; + token.tokenHash = tokenHash; + token.tokenPrefix = tokenPrefix; + token.protocols = options.protocols; + token.scopes = options.scopes; + token.createdAt = new Date(); + token.createdIp = options.createdIp; + token.usageCount = 0; + token.isRevoked = false; + + if (options.expiresInDays) { + token.expiresAt = new Date(Date.now() + options.expiresInDays * 24 * 60 * 60 * 1000); + } + + await token.save(); + + // Audit log + await this.auditService.logTokenCreated(token.id, token.name); + + return { rawToken, token }; + } + + /** + * Validate a raw token and return the token record and user + */ + public async validateToken(rawToken: string, ip?: string): Promise { + // Check token format + if (!rawToken || !rawToken.startsWith('srg_') || rawToken.length !== 68) { + return { + valid: false, + errorCode: 'INVALID_TOKEN_FORMAT', + errorMessage: 'Invalid token format', + }; + } + + // Hash and lookup + const tokenHash = await this.hashToken(rawToken); + const token = await ApiToken.findByHash(tokenHash); + + if (!token) { + return { + valid: false, + errorCode: 'TOKEN_NOT_FOUND', + errorMessage: 'Token not found', + }; + } + + // Check validity + if (!token.isValid()) { + if (token.isRevoked) { + return { + valid: false, + errorCode: 'TOKEN_REVOKED', + errorMessage: 'Token has been revoked', + }; + } + return { + valid: false, + errorCode: 'TOKEN_EXPIRED', + errorMessage: 'Token has expired', + }; + } + + // Get user + const user = await User.findById(token.userId); + if (!user) { + return { + valid: false, + errorCode: 'USER_NOT_FOUND', + errorMessage: 'Token owner not found', + }; + } + + if (!user.isActive) { + return { + valid: false, + errorCode: 'USER_INACTIVE', + errorMessage: 'Token owner account is inactive', + }; + } + + // Record usage + await token.recordUsage(ip); + + return { + valid: true, + token, + user, + }; + } + + /** + * Get all tokens for a user (without sensitive data) + */ + public async getUserTokens(userId: string): Promise { + return await ApiToken.getUserTokens(userId); + } + + /** + * Revoke a token + */ + public async revokeToken(tokenId: string, reason?: string): Promise { + const token = await ApiToken.getInstance({ id: tokenId }); + if (!token) return false; + + await token.revoke(reason); + await this.auditService.logTokenRevoked(token.id, token.name); + + return true; + } + + /** + * Revoke all tokens for a user + */ + public async revokeAllUserTokens(userId: string, reason?: string): Promise { + const tokens = await ApiToken.getUserTokens(userId); + for (const token of tokens) { + await token.revoke(reason); + await this.auditService.logTokenRevoked(token.id, token.name); + } + return tokens.length; + } + + /** + * Check if token has permission for a specific action + */ + public checkTokenPermission( + token: ApiToken, + protocol: TRegistryProtocol, + organizationId?: string, + repositoryId?: string, + action?: string + ): boolean { + if (!token.hasProtocol(protocol)) return false; + return token.hasScope(protocol, organizationId, repositoryId, action); + } + + /** + * Hash a token using SHA-256 + */ + private async hashToken(rawToken: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(rawToken); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + /** + * Generate token prefix for display + */ + public static getTokenDisplay(tokenPrefix: string): string { + return `${tokenPrefix}...`; + } +} diff --git a/ui/angular.json b/ui/angular.json new file mode 100644 index 0000000..c88fda7 --- /dev/null +++ b/ui/angular.json @@ -0,0 +1,94 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "registry-ui": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/registry-ui", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, + "configurations": { + "production": { + "buildTarget": "registry-ui:build:production" + }, + "development": { + "buildTarget": "registry-ui:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..21b4dc1 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,34 @@ +{ + "name": "@stack.gallery/registry-ui", + "version": "1.0.0", + "private": true, + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "dependencies": { + "@angular/animations": "^19.0.0", + "@angular/common": "^19.0.0", + "@angular/compiler": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0", + "@angular/platform-browser": "^19.0.0", + "@angular/platform-browser-dynamic": "^19.0.0", + "@angular/router": "^19.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.6.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^19.0.0", + "@angular/cli": "^19.0.0", + "@angular/compiler-cli": "^19.0.0", + "@types/node": "^20.0.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "~5.6.0" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/proxy.conf.json b/ui/proxy.conf.json new file mode 100644 index 0000000..527d410 --- /dev/null +++ b/ui/proxy.conf.json @@ -0,0 +1,7 @@ +{ + "/api": { + "target": "http://localhost:3000", + "secure": false, + "changeOrigin": true + } +} diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts new file mode 100644 index 0000000..83d3238 --- /dev/null +++ b/ui/src/app/app.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: ``, +}) +export class AppComponent {} diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts new file mode 100644 index 0000000..7e553b6 --- /dev/null +++ b/ui/src/app/app.config.ts @@ -0,0 +1,13 @@ +import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { routes } from './app.routes'; +import { authInterceptor } from './core/interceptors/auth.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideExperimentalZonelessChangeDetection(), + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), + ], +}; diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts new file mode 100644 index 0000000..146b645 --- /dev/null +++ b/ui/src/app/app.routes.ts @@ -0,0 +1,95 @@ +import { Routes } from '@angular/router'; +import { authGuard } from './core/guards/auth.guard'; + +export const routes: Routes = [ + { + path: 'login', + loadComponent: () => + import('./features/login/login.component').then((m) => m.LoginComponent), + }, + { + path: '', + loadComponent: () => + import('./shared/components/layout/layout.component').then( + (m) => m.LayoutComponent + ), + canActivate: [authGuard], + children: [ + { + path: '', + redirectTo: 'dashboard', + pathMatch: 'full', + }, + { + path: 'dashboard', + loadComponent: () => + import('./features/dashboard/dashboard.component').then( + (m) => m.DashboardComponent + ), + }, + { + path: 'organizations', + children: [ + { + path: '', + loadComponent: () => + import('./features/organizations/organizations.component').then( + (m) => m.OrganizationsComponent + ), + }, + { + path: ':orgId', + loadComponent: () => + import('./features/organizations/organization-detail.component').then( + (m) => m.OrganizationDetailComponent + ), + }, + { + path: ':orgId/repositories/:repoId', + loadComponent: () => + import('./features/repositories/repository-detail.component').then( + (m) => m.RepositoryDetailComponent + ), + }, + ], + }, + { + path: 'packages', + children: [ + { + path: '', + loadComponent: () => + import('./features/packages/packages.component').then( + (m) => m.PackagesComponent + ), + }, + { + path: ':packageId', + loadComponent: () => + import('./features/packages/package-detail.component').then( + (m) => m.PackageDetailComponent + ), + }, + ], + }, + { + path: 'tokens', + loadComponent: () => + import('./features/tokens/tokens.component').then( + (m) => m.TokensComponent + ), + }, + { + path: 'settings', + loadComponent: () => + import('./features/settings/settings.component').then( + (m) => m.SettingsComponent + ), + }, + ], + }, + { + path: '**', + redirectTo: 'dashboard', + }, +]; diff --git a/ui/src/app/core/guards/auth.guard.ts b/ui/src/app/core/guards/auth.guard.ts new file mode 100644 index 0000000..ce6cd98 --- /dev/null +++ b/ui/src/app/core/guards/auth.guard.ts @@ -0,0 +1,21 @@ +import { inject } from '@angular/core'; +import { Router, type CanActivateFn } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +export const authGuard: CanActivateFn = async () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isAuthenticated()) { + return true; + } + + // Try to refresh the token + const refreshed = await authService.refreshAccessToken(); + if (refreshed) { + return true; + } + + router.navigate(['/login']); + return false; +}; diff --git a/ui/src/app/core/interceptors/auth.interceptor.ts b/ui/src/app/core/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..2a9b3bd --- /dev/null +++ b/ui/src/app/core/interceptors/auth.interceptor.ts @@ -0,0 +1,59 @@ +import { inject } from '@angular/core'; +import { + HttpInterceptorFn, + HttpRequest, + HttpHandlerFn, + HttpErrorResponse, +} from '@angular/common/http'; +import { catchError, switchMap, throwError } from 'rxjs'; +import { AuthService } from '../services/auth.service'; +import { Router } from '@angular/router'; + +export const authInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn +) => { + const authService = inject(AuthService); + const router = inject(Router); + + // Skip auth header for login/refresh endpoints + if (req.url.includes('/auth/login') || req.url.includes('/auth/refresh')) { + return next(req); + } + + const token = authService.accessToken; + if (token) { + req = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}`, + }, + }); + } + + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + // Try to refresh the token + return new Promise((resolve) => { + authService.refreshAccessToken().then((success) => { + if (success) { + // Retry the request with new token + const newToken = authService.accessToken; + const retryReq = req.clone({ + setHeaders: { + Authorization: `Bearer ${newToken}`, + }, + }); + resolve(next(retryReq)); + } else { + // Redirect to login + router.navigate(['/login']); + resolve(throwError(() => error)); + } + }); + }).then((result) => result as ReturnType); + } + return throwError(() => error); + }) + ); +}; diff --git a/ui/src/app/core/services/api.service.ts b/ui/src/app/core/services/api.service.ts new file mode 100644 index 0000000..f07e850 --- /dev/null +++ b/ui/src/app/core/services/api.service.ts @@ -0,0 +1,226 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +// Types +export interface IOrganization { + id: string; + name: string; + displayName: string; + description?: string; + avatarUrl?: string; + isPublic: boolean; + memberCount: number; + createdAt: string; +} + +export interface IRepository { + id: string; + organizationId: string; + name: string; + displayName: string; + description?: string; + protocols: string[]; + isPublic: boolean; + packageCount: number; + createdAt: string; +} + +export interface IPackage { + id: string; + name: string; + description?: string; + protocol: string; + organizationId: string; + repositoryId: string; + latestVersion?: string; + isPrivate: boolean; + downloadCount: number; + updatedAt: string; +} + +export interface IToken { + id: string; + name: string; + tokenPrefix: string; + protocols: string[]; + expiresAt?: string; + lastUsedAt?: string; + usageCount: number; + createdAt: string; +} + +export interface IAuditLog { + id: string; + actorId?: string; + actorType: string; + action: string; + resourceType: string; + resourceId?: string; + resourceName?: string; + success: boolean; + timestamp: string; +} + +@Injectable({ providedIn: 'root' }) +export class ApiService { + private readonly baseUrl = '/api/v1'; + + constructor(private http: HttpClient) {} + + // Organizations + getOrganizations(): Observable<{ organizations: IOrganization[] }> { + return this.http.get<{ organizations: IOrganization[] }>( + `${this.baseUrl}/organizations` + ); + } + + getOrganization(id: string): Observable { + return this.http.get(`${this.baseUrl}/organizations/${id}`); + } + + createOrganization(data: { + name: string; + displayName?: string; + description?: string; + isPublic?: boolean; + }): Observable { + return this.http.post(`${this.baseUrl}/organizations`, data); + } + + updateOrganization( + id: string, + data: Partial + ): Observable { + return this.http.put( + `${this.baseUrl}/organizations/${id}`, + data + ); + } + + deleteOrganization(id: string): Observable<{ message: string }> { + return this.http.delete<{ message: string }>( + `${this.baseUrl}/organizations/${id}` + ); + } + + // Repositories + getRepositories(orgId: string): Observable<{ repositories: IRepository[] }> { + return this.http.get<{ repositories: IRepository[] }>( + `${this.baseUrl}/organizations/${orgId}/repositories` + ); + } + + getRepository(id: string): Observable { + return this.http.get(`${this.baseUrl}/repositories/${id}`); + } + + createRepository( + orgId: string, + data: { + name: string; + displayName?: string; + description?: string; + protocols?: string[]; + isPublic?: boolean; + } + ): Observable { + return this.http.post( + `${this.baseUrl}/organizations/${orgId}/repositories`, + data + ); + } + + updateRepository( + id: string, + data: Partial + ): Observable { + return this.http.put(`${this.baseUrl}/repositories/${id}`, data); + } + + deleteRepository(id: string): Observable<{ message: string }> { + return this.http.delete<{ message: string }>( + `${this.baseUrl}/repositories/${id}` + ); + } + + // Packages + searchPackages(params?: { + q?: string; + protocol?: string; + organizationId?: string; + limit?: number; + offset?: number; + }): Observable<{ packages: IPackage[]; total: number }> { + let httpParams = new HttpParams(); + if (params?.q) httpParams = httpParams.set('q', params.q); + if (params?.protocol) httpParams = httpParams.set('protocol', params.protocol); + if (params?.organizationId) + httpParams = httpParams.set('organizationId', params.organizationId); + if (params?.limit) httpParams = httpParams.set('limit', params.limit.toString()); + if (params?.offset) httpParams = httpParams.set('offset', params.offset.toString()); + + return this.http.get<{ packages: IPackage[]; total: number }>( + `${this.baseUrl}/packages`, + { params: httpParams } + ); + } + + getPackage(id: string): Observable { + return this.http.get( + `${this.baseUrl}/packages/${encodeURIComponent(id)}` + ); + } + + deletePackage(id: string): Observable<{ message: string }> { + return this.http.delete<{ message: string }>( + `${this.baseUrl}/packages/${encodeURIComponent(id)}` + ); + } + + // Tokens + getTokens(): Observable<{ tokens: IToken[] }> { + return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`); + } + + createToken(data: { + name: string; + protocols: string[]; + scopes: { protocol: string; actions: string[] }[]; + expiresInDays?: number; + }): Observable { + return this.http.post( + `${this.baseUrl}/tokens`, + data + ); + } + + revokeToken(id: string): Observable<{ message: string }> { + return this.http.delete<{ message: string }>(`${this.baseUrl}/tokens/${id}`); + } + + // Audit + getAuditLogs(params?: { + organizationId?: string; + resourceType?: string; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; + }): Observable<{ logs: IAuditLog[]; total: number }> { + let httpParams = new HttpParams(); + if (params?.organizationId) + httpParams = httpParams.set('organizationId', params.organizationId); + if (params?.resourceType) + httpParams = httpParams.set('resourceType', params.resourceType); + if (params?.startDate) httpParams = httpParams.set('startDate', params.startDate); + if (params?.endDate) httpParams = httpParams.set('endDate', params.endDate); + if (params?.limit) httpParams = httpParams.set('limit', params.limit.toString()); + if (params?.offset) httpParams = httpParams.set('offset', params.offset.toString()); + + return this.http.get<{ logs: IAuditLog[]; total: number }>( + `${this.baseUrl}/audit`, + { params: httpParams } + ); + } +} diff --git a/ui/src/app/core/services/auth.service.ts b/ui/src/app/core/services/auth.service.ts new file mode 100644 index 0000000..b5ec374 --- /dev/null +++ b/ui/src/app/core/services/auth.service.ts @@ -0,0 +1,148 @@ +import { Injectable, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Router } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; + +export interface IUser { + id: string; + email: string; + username: string; + displayName: string; + avatarUrl?: string; + isSystemAdmin: boolean; +} + +export interface ILoginResponse { + user: IUser; + accessToken: string; + refreshToken: string; + sessionId: string; +} + +@Injectable({ providedIn: 'root' }) +export class AuthService { + private readonly _user = signal(null); + private readonly _accessToken = signal(null); + private readonly _refreshToken = signal(null); + private readonly _sessionId = signal(null); + + readonly user = this._user.asReadonly(); + readonly isAuthenticated = computed(() => !!this._accessToken()); + readonly isAdmin = computed(() => this._user()?.isSystemAdmin ?? false); + + constructor( + private http: HttpClient, + private router: Router + ) { + this.loadFromStorage(); + } + + get accessToken(): string | null { + return this._accessToken(); + } + + async login(email: string, password: string): Promise { + try { + const response = await firstValueFrom( + this.http.post('/api/v1/auth/login', { email, password }) + ); + + this._user.set(response.user); + this._accessToken.set(response.accessToken); + this._refreshToken.set(response.refreshToken); + this._sessionId.set(response.sessionId); + + this.saveToStorage(); + return true; + } catch (error) { + console.error('Login failed:', error); + return false; + } + } + + async logout(): Promise { + try { + const sessionId = this._sessionId(); + if (sessionId) { + await firstValueFrom( + this.http.post('/api/v1/auth/logout', { sessionId }) + ).catch(() => {}); + } + } finally { + this.clearAuth(); + this.router.navigate(['/login']); + } + } + + async refreshAccessToken(): Promise { + const refreshToken = this._refreshToken(); + if (!refreshToken) return false; + + try { + const response = await firstValueFrom( + this.http.post<{ accessToken: string }>('/api/v1/auth/refresh', { + refreshToken, + }) + ); + + this._accessToken.set(response.accessToken); + this.saveToStorage(); + return true; + } catch { + this.clearAuth(); + return false; + } + } + + async fetchCurrentUser(): Promise { + try { + const user = await firstValueFrom( + this.http.get('/api/v1/auth/me') + ); + this._user.set(user); + return user; + } catch { + return null; + } + } + + private loadFromStorage(): void { + const accessToken = localStorage.getItem('accessToken'); + const refreshToken = localStorage.getItem('refreshToken'); + const sessionId = localStorage.getItem('sessionId'); + const userJson = localStorage.getItem('user'); + + if (accessToken) this._accessToken.set(accessToken); + if (refreshToken) this._refreshToken.set(refreshToken); + if (sessionId) this._sessionId.set(sessionId); + if (userJson) { + try { + this._user.set(JSON.parse(userJson)); + } catch {} + } + } + + private saveToStorage(): void { + const accessToken = this._accessToken(); + const refreshToken = this._refreshToken(); + const sessionId = this._sessionId(); + const user = this._user(); + + if (accessToken) localStorage.setItem('accessToken', accessToken); + if (refreshToken) localStorage.setItem('refreshToken', refreshToken); + if (sessionId) localStorage.setItem('sessionId', sessionId); + if (user) localStorage.setItem('user', JSON.stringify(user)); + } + + private clearAuth(): void { + this._user.set(null); + this._accessToken.set(null); + this._refreshToken.set(null); + this._sessionId.set(null); + + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('sessionId'); + localStorage.removeItem('user'); + } +} diff --git a/ui/src/app/core/services/toast.service.ts b/ui/src/app/core/services/toast.service.ts new file mode 100644 index 0000000..7f59239 --- /dev/null +++ b/ui/src/app/core/services/toast.service.ts @@ -0,0 +1,50 @@ +import { Injectable, signal } from '@angular/core'; + +export interface IToast { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + title: string; + message?: string; + duration?: number; +} + +@Injectable({ providedIn: 'root' }) +export class ToastService { + private _toasts = signal([]); + readonly toasts = this._toasts.asReadonly(); + + show(toast: Omit): void { + const id = crypto.randomUUID(); + const newToast: IToast = { ...toast, id }; + this._toasts.update((toasts) => [...toasts, newToast]); + + const duration = toast.duration ?? 5000; + if (duration > 0) { + setTimeout(() => this.dismiss(id), duration); + } + } + + success(title: string, message?: string): void { + this.show({ type: 'success', title, message }); + } + + error(title: string, message?: string): void { + this.show({ type: 'error', title, message }); + } + + warning(title: string, message?: string): void { + this.show({ type: 'warning', title, message }); + } + + info(title: string, message?: string): void { + this.show({ type: 'info', title, message }); + } + + dismiss(id: string): void { + this._toasts.update((toasts) => toasts.filter((t) => t.id !== id)); + } + + clear(): void { + this._toasts.set([]); + } +} diff --git a/ui/src/app/features/dashboard/dashboard.component.ts b/ui/src/app/features/dashboard/dashboard.component.ts new file mode 100644 index 0000000..632f73f --- /dev/null +++ b/ui/src/app/features/dashboard/dashboard.component.ts @@ -0,0 +1,220 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ApiService, type IOrganization, type IPackage } from '../../core/services/api.service'; +import { AuthService } from '../../core/services/auth.service'; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [RouterLink], + template: ` +
+ +
+

Dashboard

+

Welcome back, {{ userName() }}

+
+ + +
+
+
+
+ + + +
+
+

Organizations

+

{{ organizations().length }}

+
+
+
+ +
+
+
+ + + +
+
+

Packages

+

{{ packages().length }}

+
+
+
+ +
+
+
+ + + +
+
+

Total Downloads

+

{{ totalDownloads() }}

+
+
+
+ +
+
+
+ + + +
+
+

Last Activity

+

Today

+
+
+
+
+ + +
+ +
+
+

Recent Packages

+ View all +
+
+ @if (packages().length === 0) { +
+ No packages yet +
+ } @else { +
    + @for (pkg of packages().slice(0, 5); track pkg.id) { +
  • +
    +
    +

    {{ pkg.name }}

    +

    {{ pkg.protocol }} · {{ pkg.latestVersion || 'No versions' }}

    +
    + {{ pkg.downloadCount }} downloads +
    +
  • + } +
+ } +
+
+ + +
+
+

Your Organizations

+ View all +
+
+ @if (organizations().length === 0) { +
+ No organizations yet +
+ } @else { + + } +
+
+
+ + + +
+ `, +}) +export class DashboardComponent implements OnInit { + private authService = inject(AuthService); + private apiService = inject(ApiService); + + organizations = signal([]); + packages = signal([]); + totalDownloads = signal(0); + + userName = () => this.authService.user()?.displayName || 'User'; + + ngOnInit(): void { + this.loadData(); + } + + private async loadData(): Promise { + try { + const [orgsResponse, packagesResponse] = await Promise.all([ + this.apiService.getOrganizations().toPromise(), + this.apiService.searchPackages({ limit: 10 }).toPromise(), + ]); + + this.organizations.set(orgsResponse?.organizations || []); + this.packages.set(packagesResponse?.packages || []); + + const totalDownloads = (packagesResponse?.packages || []).reduce( + (sum, pkg) => sum + pkg.downloadCount, + 0 + ); + this.totalDownloads.set(totalDownloads); + } catch (error) { + console.error('Failed to load dashboard data:', error); + } + } +} diff --git a/ui/src/app/features/login/login.component.ts b/ui/src/app/features/login/login.component.ts new file mode 100644 index 0000000..27e5cd7 --- /dev/null +++ b/ui/src/app/features/login/login.component.ts @@ -0,0 +1,121 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService } from '../../core/services/auth.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [FormsModule], + template: ` +
+
+ +
+
+ + + +
+

Stack.Gallery Registry

+

Sign in to your account

+
+ + +
+
+
+ + +
+ +
+ + +
+
+ + @if (error()) { +
+

{{ error() }}

+
+ } + + +
+ +

+ Enterprise Package Registry +

+
+
+ `, +}) +export class LoginComponent { + private authService = inject(AuthService); + private router = inject(Router); + private toastService = inject(ToastService); + + email = ''; + password = ''; + loading = signal(false); + error = signal(null); + + async login(): Promise { + if (!this.email || !this.password) { + this.error.set('Please enter your email and password'); + return; + } + + this.loading.set(true); + this.error.set(null); + + try { + const success = await this.authService.login(this.email, this.password); + + if (success) { + this.toastService.success('Welcome back!'); + this.router.navigate(['/dashboard']); + } else { + this.error.set('Invalid email or password'); + } + } catch (err) { + this.error.set('An error occurred. Please try again.'); + } finally { + this.loading.set(false); + } + } +} diff --git a/ui/src/app/features/organizations/organization-detail.component.ts b/ui/src/app/features/organizations/organization-detail.component.ts new file mode 100644 index 0000000..983cc6b --- /dev/null +++ b/ui/src/app/features/organizations/organization-detail.component.ts @@ -0,0 +1,151 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ApiService, type IOrganization, type IRepository } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-organization-detail', + standalone: true, + imports: [RouterLink], + template: ` +
+ @if (loading()) { +
+ + + + +
+ } @else if (organization()) { + +
+
+
+ + {{ organization()!.name.charAt(0).toUpperCase() }} + +
+
+

{{ organization()!.displayName }}

+

@{{ organization()!.name }}

+
+
+
+ @if (organization()!.isPublic) { + Public + } @else { + Private + } +
+
+ + @if (organization()!.description) { +

{{ organization()!.description }}

+ } + + +
+
+

Repositories

+ +
+ + @if (repositories().length === 0) { +
+

No repositories yet

+
+ } @else { + + } +
+ + +
+
+

Members

+

{{ organization()!.memberCount }}

+
+
+

Repositories

+

{{ repositories().length }}

+
+
+

Created

+

{{ formatDate(organization()!.createdAt) }}

+
+
+ } +
+ `, +}) +export class OrganizationDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private apiService = inject(ApiService); + private toastService = inject(ToastService); + + organization = signal(null); + repositories = signal([]); + loading = signal(true); + + ngOnInit(): void { + const orgId = this.route.snapshot.paramMap.get('orgId'); + if (orgId) { + this.loadData(orgId); + } + } + + private async loadData(orgId: string): Promise { + this.loading.set(true); + try { + const [org, reposResponse] = await Promise.all([ + this.apiService.getOrganization(orgId).toPromise(), + this.apiService.getRepositories(orgId).toPromise(), + ]); + this.organization.set(org || null); + this.repositories.set(reposResponse?.repositories || []); + } catch (error) { + this.toastService.error('Failed to load organization'); + } finally { + this.loading.set(false); + } + } + + formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(); + } +} diff --git a/ui/src/app/features/organizations/organizations.component.ts b/ui/src/app/features/organizations/organizations.component.ts new file mode 100644 index 0000000..a672ee3 --- /dev/null +++ b/ui/src/app/features/organizations/organizations.component.ts @@ -0,0 +1,210 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { ApiService, type IOrganization } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-organizations', + standalone: true, + imports: [RouterLink, FormsModule], + template: ` +
+
+
+

Organizations

+

Manage your organizations and repositories

+
+ +
+ + @if (loading()) { +
+ + + + +
+ } @else if (organizations().length === 0) { +
+ + + +

No organizations yet

+

Create your first organization to start managing packages

+ +
+ } @else { + + } + + + @if (showCreateModal()) { +
+
+
+

Create Organization

+ +
+
+
+ + +

Lowercase letters, numbers, and hyphens only

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ } +
+ `, +}) +export class OrganizationsComponent implements OnInit { + private apiService = inject(ApiService); + private toastService = inject(ToastService); + + organizations = signal([]); + loading = signal(true); + showCreateModal = signal(false); + creating = signal(false); + + newOrg = { + name: '', + displayName: '', + description: '', + isPublic: false, + }; + + ngOnInit(): void { + this.loadOrganizations(); + } + + private async loadOrganizations(): Promise { + this.loading.set(true); + try { + const response = await this.apiService.getOrganizations().toPromise(); + this.organizations.set(response?.organizations || []); + } catch (error) { + this.toastService.error('Failed to load organizations'); + } finally { + this.loading.set(false); + } + } + + async createOrganization(): Promise { + if (!this.newOrg.name) return; + + this.creating.set(true); + try { + const org = await this.apiService.createOrganization({ + name: this.newOrg.name, + displayName: this.newOrg.displayName || this.newOrg.name, + description: this.newOrg.description, + isPublic: this.newOrg.isPublic, + }).toPromise(); + + if (org) { + this.organizations.update((orgs) => [...orgs, org]); + this.toastService.success('Organization created successfully'); + this.showCreateModal.set(false); + this.newOrg = { name: '', displayName: '', description: '', isPublic: false }; + } + } catch (error) { + this.toastService.error('Failed to create organization'); + } finally { + this.creating.set(false); + } + } +} diff --git a/ui/src/app/features/packages/package-detail.component.ts b/ui/src/app/features/packages/package-detail.component.ts new file mode 100644 index 0000000..74ebff8 --- /dev/null +++ b/ui/src/app/features/packages/package-detail.component.ts @@ -0,0 +1,186 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ApiService, type IPackage } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-package-detail', + standalone: true, + imports: [RouterLink], + template: ` +
+ @if (loading()) { +
+ + + + +
+ } @else if (pkg()) { + + +
+
+
+

{{ pkg()!.name }}

+ {{ pkg()!.protocol }} + @if (pkg()!.isPrivate) { + Private + } +
+ @if (pkg()!.description) { +

{{ pkg()!.description }}

+ } +
+
+ +
+ +
+ +
+
+

Installation

+
+
+ @switch (pkg()!.protocol) { + @case ('npm') { + + npm install {{ pkg()!.name }} + + } + @case ('oci') { + + docker pull registry.stack.gallery/{{ pkg()!.name }}:{{ pkg()!.latestVersion || 'latest' }} + + } + @case ('maven') { + + <dependency>
+   <groupId>{{ pkg()!.name.split(':')[0] }}</groupId>
+   <artifactId>{{ pkg()!.name.split(':')[1] || pkg()!.name }}</artifactId>
+   <version>{{ pkg()!.latestVersion || 'LATEST' }}</version>
+ </dependency> +
+ } + @case ('pypi') { + + pip install {{ pkg()!.name }} + + } + @case ('cargo') { + + cargo add {{ pkg()!.name }} + + } + @case ('composer') { + + composer require {{ pkg()!.name }} + + } + @case ('rubygems') { + + gem install {{ pkg()!.name }} + + } + @default { +

Installation instructions not available

+ } + } +
+
+ + +
+
+

Versions

+
+
+ @if (versions().length === 0) { +
+ No versions published yet +
+ } @else { +
    + @for (version of versions(); track version.version) { +
  • +
    + {{ version.version }} + @if (version.version === pkg()!.latestVersion) { + latest + } +
    + + {{ version.downloads }} downloads + +
  • + } +
+ } +
+
+
+ + +
+
+

Stats

+
+
+
Downloads
+
{{ pkg()!.downloadCount }}
+
+
+
Latest version
+
{{ pkg()!.latestVersion || 'N/A' }}
+
+
+
Last updated
+
{{ formatDate(pkg()!.updatedAt) }}
+
+
+
+
+
+ } +
+ `, +}) +export class PackageDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private apiService = inject(ApiService); + private toastService = inject(ToastService); + + pkg = signal(null); + versions = signal<{ version: string; downloads: number }[]>([]); + loading = signal(true); + + ngOnInit(): void { + const packageId = this.route.snapshot.paramMap.get('packageId'); + if (packageId) { + this.loadPackage(packageId); + } + } + + private async loadPackage(packageId: string): Promise { + this.loading.set(true); + try { + const pkg = await this.apiService.getPackage(packageId).toPromise(); + this.pkg.set(pkg || null); + // Versions would come from the full package response in a real implementation + this.versions.set([]); + } catch (error) { + this.toastService.error('Failed to load package'); + } finally { + this.loading.set(false); + } + } + + formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(); + } +} diff --git a/ui/src/app/features/packages/packages.component.ts b/ui/src/app/features/packages/packages.component.ts new file mode 100644 index 0000000..d1338eb --- /dev/null +++ b/ui/src/app/features/packages/packages.component.ts @@ -0,0 +1,179 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { ApiService, type IPackage } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-packages', + standalone: true, + imports: [RouterLink, FormsModule], + template: ` +
+
+

Packages

+

Browse and search all available packages

+
+ + +
+
+
+ +
+ +
+
+ + @if (loading()) { +
+ + + + +
+ } @else if (packages().length === 0) { +
+ + + +

No packages found

+

+ @if (searchQuery || selectedProtocol) { + Try adjusting your search or filters + } @else { + Publish your first package to get started + } +

+
+ } @else { + + + @if (total() > packages().length) { +
+ +
+ } + } +
+ `, +}) +export class PackagesComponent implements OnInit { + private apiService = inject(ApiService); + private toastService = inject(ToastService); + + packages = signal([]); + total = signal(0); + loading = signal(true); + + searchQuery = ''; + selectedProtocol = ''; + private offset = 0; + private readonly limit = 20; + private searchTimeout?: ReturnType; + + ngOnInit(): void { + this.loadPackages(); + } + + search(): void { + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + this.offset = 0; + this.loadPackages(); + }, 300); + } + + async loadPackages(): Promise { + this.loading.set(true); + try { + const response = await this.apiService.searchPackages({ + q: this.searchQuery || undefined, + protocol: this.selectedProtocol || undefined, + limit: this.limit, + offset: this.offset, + }).toPromise(); + + if (this.offset === 0) { + this.packages.set(response?.packages || []); + } else { + this.packages.update((pkgs) => [...pkgs, ...(response?.packages || [])]); + } + this.total.set(response?.total || 0); + } catch (error) { + this.toastService.error('Failed to load packages'); + } finally { + this.loading.set(false); + } + } + + loadMore(): void { + this.offset += this.limit; + this.loadPackages(); + } + + formatDate(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 7) return `${days} days ago`; + if (days < 30) return `${Math.floor(days / 7)} weeks ago`; + return date.toLocaleDateString(); + } +} diff --git a/ui/src/app/features/repositories/repository-detail.component.ts b/ui/src/app/features/repositories/repository-detail.component.ts new file mode 100644 index 0000000..2084246 --- /dev/null +++ b/ui/src/app/features/repositories/repository-detail.component.ts @@ -0,0 +1,121 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ApiService, type IRepository, type IPackage } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-repository-detail', + standalone: true, + imports: [RouterLink], + template: ` +
+ @if (loading()) { +
+ + + + +
+ } @else if (repository()) { + + +
+
+

{{ repository()!.displayName }}

+

{{ repository()!.name }}

+
+
+ @for (protocol of repository()!.protocols; track protocol) { + {{ protocol }} + } + @if (repository()!.isPublic) { + Public + } +
+
+ + @if (repository()!.description) { +

{{ repository()!.description }}

+ } + + +
+
+

Packages

+
+
+ @if (packages().length === 0) { +
+ No packages in this repository yet +
+ } @else { +
    + @for (pkg of packages(); track pkg.id) { +
  • +
    +
    +

    {{ pkg.name }}

    +

    + {{ pkg.protocol }} · {{ pkg.latestVersion || 'No versions' }} +

    +
    +
    + + {{ pkg.downloadCount }} downloads + + + View + +
    +
    +
  • + } +
+ } +
+
+ } +
+ `, +}) +export class RepositoryDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private apiService = inject(ApiService); + private toastService = inject(ToastService); + + repository = signal(null); + packages = signal([]); + loading = signal(true); + + ngOnInit(): void { + const repoId = this.route.snapshot.paramMap.get('repoId'); + if (repoId) { + this.loadData(repoId); + } + } + + private async loadData(repoId: string): Promise { + this.loading.set(true); + try { + const repo = await this.apiService.getRepository(repoId).toPromise(); + this.repository.set(repo || null); + + if (repo) { + const packagesResponse = await this.apiService.searchPackages({ + organizationId: repo.organizationId, + }).toPromise(); + this.packages.set( + (packagesResponse?.packages || []).filter((p) => p.repositoryId === repoId) + ); + } + } catch (error) { + this.toastService.error('Failed to load repository'); + } finally { + this.loading.set(false); + } + } +} diff --git a/ui/src/app/features/settings/settings.component.ts b/ui/src/app/features/settings/settings.component.ts new file mode 100644 index 0000000..38ac7f7 --- /dev/null +++ b/ui/src/app/features/settings/settings.component.ts @@ -0,0 +1,218 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AuthService, type IUser } from '../../core/services/auth.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-settings', + standalone: true, + imports: [FormsModule], + template: ` +
+

Account Settings

+ + +
+
+

Profile

+
+
+
+
+ + {{ userInitial() }} + +
+
+

{{ user()?.displayName }}

+

{{ user()?.email }}

+
+
+ +
+ + +
+ +
+ + +

Username cannot be changed

+
+ +
+ + +

Contact support to change your email

+
+
+ +
+ + +
+
+

Security

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+

Sessions

+
+
+

+ Sign out of all other browser sessions. This will not affect your current session. +

+ +
+
+ + +
+
+

Danger Zone

+
+
+

+ Once you delete your account, there is no going back. Please be certain. +

+ +
+
+
+ `, +}) +export class SettingsComponent implements OnInit { + private authService = inject(AuthService); + private toastService = inject(ToastService); + + user = this.authService.user; + displayName = ''; + currentPassword = ''; + newPassword = ''; + confirmPassword = ''; + + saving = signal(false); + changingPassword = signal(false); + + userInitial = () => { + const name = this.user()?.displayName || 'U'; + return name.charAt(0).toUpperCase(); + }; + + ngOnInit(): void { + this.displayName = this.user()?.displayName || ''; + } + + async saveProfile(): Promise { + this.saving.set(true); + try { + // Would call API to update profile + this.toastService.success('Profile updated'); + } catch (error) { + this.toastService.error('Failed to update profile'); + } finally { + this.saving.set(false); + } + } + + async changePassword(): Promise { + if (!this.currentPassword || !this.newPassword) { + this.toastService.error('Please fill in all password fields'); + return; + } + + if (this.newPassword !== this.confirmPassword) { + this.toastService.error('New passwords do not match'); + return; + } + + this.changingPassword.set(true); + try { + // Would call API to change password + this.toastService.success('Password changed'); + this.currentPassword = ''; + this.newPassword = ''; + this.confirmPassword = ''; + } catch (error) { + this.toastService.error('Failed to change password'); + } finally { + this.changingPassword.set(false); + } + } + + async logoutAllSessions(): Promise { + try { + // Would call API to logout all sessions + this.toastService.success('Signed out of all other sessions'); + } catch (error) { + this.toastService.error('Failed to sign out'); + } + } +} diff --git a/ui/src/app/features/tokens/tokens.component.ts b/ui/src/app/features/tokens/tokens.component.ts new file mode 100644 index 0000000..be59b75 --- /dev/null +++ b/ui/src/app/features/tokens/tokens.component.ts @@ -0,0 +1,280 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ApiService, type IToken } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; + +@Component({ + selector: 'app-tokens', + standalone: true, + imports: [FormsModule], + template: ` +
+
+
+

API Tokens

+

Manage your API tokens for registry access

+
+ +
+ + @if (loading()) { +
+ + + + +
+ } @else if (tokens().length === 0) { +
+ + + +

No API tokens

+

Create a token to authenticate with the registry

+ +
+ } @else { +
+
    + @for (token of tokens(); track token.id) { +
  • +
    +
    +
    +

    {{ token.name }}

    + @for (protocol of token.protocols.slice(0, 3); track protocol) { + {{ protocol }} + } + @if (token.protocols.length > 3) { + +{{ token.protocols.length - 3 }} + } +
    +

    + {{ token.tokenPrefix }}... + @if (token.expiresAt) { + · + Expires {{ formatDate(token.expiresAt) }} + } +

    +

    + Created {{ formatDate(token.createdAt) }} + @if (token.lastUsedAt) { + · Last used {{ formatDate(token.lastUsedAt) }} + } + · {{ token.usageCount }} uses +

    +
    + +
    +
  • + } +
+
+ } + + + @if (showCreateModal()) { +
+
+
+

Create API Token

+ +
+
+
+ + +
+
+ +
+ @for (protocol of availableProtocols; track protocol) { + + } +
+
+
+ + +
+
+ +
+
+ } + + + @if (createdToken()) { +
+
+
+

Token Created

+
+
+
+

+ Make sure to copy your token now. You won't be able to see it again! +

+
+
+ +
+ + {{ createdToken() }} + + +
+
+
+ +
+
+ } +
+ `, +}) +export class TokensComponent implements OnInit { + private apiService = inject(ApiService); + private toastService = inject(ToastService); + + tokens = signal([]); + loading = signal(true); + showCreateModal = signal(false); + creating = signal(false); + createdToken = signal(null); + + availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems']; + + newToken = { + name: '', + protocols: [] as string[], + expiresInDays: null as number | null, + }; + + ngOnInit(): void { + this.loadTokens(); + } + + private async loadTokens(): Promise { + this.loading.set(true); + try { + const response = await this.apiService.getTokens().toPromise(); + this.tokens.set(response?.tokens || []); + } catch (error) { + this.toastService.error('Failed to load tokens'); + } finally { + this.loading.set(false); + } + } + + toggleProtocol(protocol: string): void { + if (this.newToken.protocols.includes(protocol)) { + this.newToken.protocols = this.newToken.protocols.filter((p) => p !== protocol); + } else { + this.newToken.protocols = [...this.newToken.protocols, protocol]; + } + } + + async createToken(): Promise { + if (!this.newToken.name || this.newToken.protocols.length === 0) return; + + this.creating.set(true); + try { + const response = await this.apiService.createToken({ + name: this.newToken.name, + protocols: this.newToken.protocols, + scopes: this.newToken.protocols.map((p) => ({ + protocol: p, + actions: ['read', 'write'], + })), + expiresInDays: this.newToken.expiresInDays || undefined, + }).toPromise(); + + if (response) { + this.createdToken.set(response.token); + this.tokens.update((tokens) => [response, ...tokens]); + this.showCreateModal.set(false); + this.newToken = { name: '', protocols: [], expiresInDays: null }; + } + } catch (error) { + this.toastService.error('Failed to create token'); + } finally { + this.creating.set(false); + } + } + + async revokeToken(token: IToken): Promise { + if (!confirm(`Are you sure you want to revoke "${token.name}"? This cannot be undone.`)) return; + + try { + await this.apiService.revokeToken(token.id).toPromise(); + this.tokens.update((tokens) => tokens.filter((t) => t.id !== token.id)); + this.toastService.success('Token revoked'); + } catch (error) { + this.toastService.error('Failed to revoke token'); + } + } + + closeCreateModal(): void { + this.showCreateModal.set(false); + this.newToken = { name: '', protocols: [], expiresInDays: null }; + } + + copyToken(): void { + const token = this.createdToken(); + if (token) { + navigator.clipboard.writeText(token); + this.toastService.success('Token copied to clipboard'); + } + } + + formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(); + } +} diff --git a/ui/src/app/shared/components/layout/layout.component.ts b/ui/src/app/shared/components/layout/layout.component.ts new file mode 100644 index 0000000..4888ee0 --- /dev/null +++ b/ui/src/app/shared/components/layout/layout.component.ts @@ -0,0 +1,115 @@ +import { Component, computed, inject } from '@angular/core'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { AuthService } from '../../../core/services/auth.service'; +import { ToastService } from '../../../core/services/toast.service'; + +@Component({ + selector: 'app-layout', + standalone: true, + imports: [RouterOutlet, RouterLink, RouterLinkActive], + template: ` +
+ + + + +
+ +
+
+ `, +}) +export class LayoutComponent { + private authService = inject(AuthService); + + userName = computed(() => this.authService.user()?.displayName || 'User'); + userEmail = computed(() => this.authService.user()?.email || ''); + userInitial = computed(() => { + const name = this.authService.user()?.displayName || 'U'; + return name.charAt(0).toUpperCase(); + }); + + logout(): void { + this.authService.logout(); + } +} diff --git a/ui/src/index.html b/ui/src/index.html new file mode 100644 index 0000000..672501c --- /dev/null +++ b/ui/src/index.html @@ -0,0 +1,17 @@ + + + + + Stack.Gallery Registry + + + + + + + + + + + + diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..514c89a --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err) +); diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..77cf999 --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,144 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 199 89% 48%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 199 89% 48%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 199 89% 48%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 199 89% 48%; + } +} + +@layer base { + * { + @apply border-gray-200 dark:border-gray-800; + } + + body { + @apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 + disabled:pointer-events-none disabled:opacity-50; + } + + .btn-primary { + @apply btn bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500; + } + + .btn-secondary { + @apply btn bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700; + } + + .btn-ghost { + @apply btn hover:bg-gray-100 dark:hover:bg-gray-800; + } + + .btn-sm { + @apply h-8 px-3 text-xs; + } + + .btn-md { + @apply h-10 px-4; + } + + .btn-lg { + @apply h-12 px-6; + } + + .input { + @apply flex h-10 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 + px-3 py-2 text-sm placeholder:text-gray-400 dark:placeholder:text-gray-500 + focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent + disabled:cursor-not-allowed disabled:opacity-50; + } + + .label { + @apply text-sm font-medium text-gray-700 dark:text-gray-300; + } + + .card { + @apply rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-800 shadow-sm; + } + + .card-header { + @apply px-6 py-4 border-b border-gray-200 dark:border-gray-700; + } + + .card-content { + @apply px-6 py-4; + } + + .card-footer { + @apply px-6 py-4 border-t border-gray-200 dark:border-gray-700; + } + + .badge { + @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium; + } + + .badge-default { + @apply badge bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200; + } + + .badge-primary { + @apply badge bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200; + } + + .badge-success { + @apply badge bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200; + } + + .badge-warning { + @apply badge bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200; + } + + .badge-destructive { + @apply badge bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200; + } +} diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js new file mode 100644 index 0000000..c3f5477 --- /dev/null +++ b/ui/tailwind.config.js @@ -0,0 +1,44 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{html,ts}", + ], + darkMode: 'class', + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + 950: '#082f49', + }, + accent: { + 50: '#fdf4ff', + 100: '#fae8ff', + 200: '#f5d0fe', + 300: '#f0abfc', + 400: '#e879f9', + 500: '#d946ef', + 600: '#c026d3', + 700: '#a21caf', + 800: '#86198f', + 900: '#701a75', + 950: '#4a044e', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + mono: ['JetBrains Mono', 'monospace'], + }, + }, + }, + plugins: [], +} diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json new file mode 100644 index 0000000..5b9d3c5 --- /dev/null +++ b/ui/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..fb9e5aa --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022", "dom"], + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +}