feat: implement account settings and API tokens management
- Added SettingsComponent for user profile management, including display name and password change functionality. - Introduced TokensComponent for managing API tokens, including creation and revocation. - Created LayoutComponent for consistent application layout with navigation and user information. - Established main application structure in index.html and main.ts. - Integrated Tailwind CSS for styling and responsive design. - Configured TypeScript settings for strict type checking and module resolution.
This commit is contained in:
109
ts/api/handlers/audit.api.ts
Normal file
109
ts/api/handlers/audit.api.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Audit API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditLog } from '../../models/auditlog.ts';
|
||||
import type { TAuditAction, TAuditResourceType } from '../../interfaces/audit.interfaces.ts';
|
||||
|
||||
export class AuditApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit
|
||||
*/
|
||||
public async query(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse query parameters
|
||||
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
||||
const repositoryId = ctx.url.searchParams.get('repositoryId') || undefined;
|
||||
const resourceType = ctx.url.searchParams.get('resourceType') as TAuditResourceType | undefined;
|
||||
const actionsParam = ctx.url.searchParams.get('actions');
|
||||
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
|
||||
const success = ctx.url.searchParams.has('success')
|
||||
? ctx.url.searchParams.get('success') === 'true'
|
||||
: undefined;
|
||||
const startDateParam = ctx.url.searchParams.get('startDate');
|
||||
const endDateParam = ctx.url.searchParams.get('endDate');
|
||||
const startDate = startDateParam ? new Date(startDateParam) : undefined;
|
||||
const endDate = endDateParam ? new Date(endDateParam) : undefined;
|
||||
const limit = parseInt(ctx.url.searchParams.get('limit') || '100', 10);
|
||||
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
// Check permissions
|
||||
// Users can view audit logs for:
|
||||
// 1. Their own actions (actorId = userId)
|
||||
// 2. Organizations they manage
|
||||
// 3. System admins can view all
|
||||
|
||||
let actorId: string | undefined;
|
||||
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
// System admins can see all
|
||||
actorId = ctx.url.searchParams.get('actorId') || undefined;
|
||||
} else if (organizationId) {
|
||||
// Check if user can manage this org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId
|
||||
);
|
||||
if (!canManage) {
|
||||
// User can only see their own actions in this org
|
||||
actorId = ctx.actor.userId;
|
||||
}
|
||||
} else {
|
||||
// Non-admins without org filter can only see their own actions
|
||||
actorId = ctx.actor.userId;
|
||||
}
|
||||
|
||||
const result = await AuditLog.query({
|
||||
actorId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
resourceType,
|
||||
action: actions,
|
||||
success,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
logs: result.logs.map((log) => ({
|
||||
id: log.id,
|
||||
actorId: log.actorId,
|
||||
actorType: log.actorType,
|
||||
action: log.action,
|
||||
resourceType: log.resourceType,
|
||||
resourceId: log.resourceId,
|
||||
resourceName: log.resourceName,
|
||||
organizationId: log.organizationId,
|
||||
repositoryId: log.repositoryId,
|
||||
success: log.success,
|
||||
errorCode: log.errorCode,
|
||||
timestamp: log.timestamp,
|
||||
metadata: log.metadata,
|
||||
})),
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuditApi] Query error:', error);
|
||||
return { status: 500, body: { error: 'Failed to query audit logs' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
184
ts/api/handlers/auth.api.ts
Normal file
184
ts/api/handlers/auth.api.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Auth API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
|
||||
export class AuthApi {
|
||||
private authService: AuthService;
|
||||
|
||||
constructor(authService: AuthService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/login
|
||||
*/
|
||||
public async login(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Email and password are required' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.authService.login(email, password, {
|
||||
userAgent: ctx.userAgent,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: result.errorMessage,
|
||||
code: result.errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
user: {
|
||||
id: result.user!.id,
|
||||
email: result.user!.email,
|
||||
username: result.user!.username,
|
||||
displayName: result.user!.displayName,
|
||||
isSystemAdmin: result.user!.isSystemAdmin,
|
||||
},
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionId: result.sessionId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Login error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Login failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/refresh
|
||||
*/
|
||||
public async refresh(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { refreshToken } = body;
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Refresh token is required' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.authService.refresh(refreshToken);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: result.errorMessage,
|
||||
code: result.errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Refresh error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Token refresh failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/logout
|
||||
*/
|
||||
public async logout(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return {
|
||||
status: 401,
|
||||
body: { error: 'Authentication required' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json().catch(() => ({}));
|
||||
const { sessionId, all } = body;
|
||||
|
||||
if (all) {
|
||||
const count = await this.authService.logoutAll(ctx.actor.userId, {
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: `Logged out from ${count} sessions` },
|
||||
};
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
await this.authService.logout(sessionId, {
|
||||
userId: ctx.actor.userId,
|
||||
ipAddress: ctx.ip,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Logged out successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthApi] Logout error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Logout failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/me
|
||||
*/
|
||||
public async me(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId || !ctx.actor.user) {
|
||||
return {
|
||||
status: 401,
|
||||
body: { error: 'Authentication required' },
|
||||
};
|
||||
}
|
||||
|
||||
const user = ctx.actor.user;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
11
ts/api/handlers/index.ts
Normal file
11
ts/api/handlers/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* API handler exports
|
||||
*/
|
||||
|
||||
export { AuthApi } from './auth.api.ts';
|
||||
export { UserApi } from './user.api.ts';
|
||||
export { OrganizationApi } from './organization.api.ts';
|
||||
export { RepositoryApi } from './repository.api.ts';
|
||||
export { PackageApi } from './package.api.ts';
|
||||
export { TokenApi } from './token.api.ts';
|
||||
export { AuditApi } from './audit.api.ts';
|
||||
494
ts/api/handlers/organization.api.ts
Normal file
494
ts/api/handlers/organization.api.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Organization API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import { Organization, OrganizationMember, User } from '../../models/index.ts';
|
||||
import type { TOrganizationRole } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class OrganizationApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
// System admins see all orgs, others see only their orgs
|
||||
let organizations: Organization[];
|
||||
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
organizations = await Organization.getInstances({});
|
||||
} else {
|
||||
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
organizations: organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
createdAt: org.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list organizations' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check access - public orgs are visible to all authenticated users
|
||||
if (!org.isPublic && ctx.actor?.userId) {
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
settings: ctx.actor?.user?.isSystemAdmin ? org.settings : undefined,
|
||||
usedStorageBytes: org.usedStorageBytes,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, displayName, description, isPublic } = body;
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Organization name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if name is taken
|
||||
const existing = await Organization.findByName(name);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Organization name already taken' } };
|
||||
}
|
||||
|
||||
// Create organization
|
||||
const org = new Organization();
|
||||
org.id = await Organization.getNewId();
|
||||
org.name = name;
|
||||
org.displayName = displayName || name;
|
||||
org.description = description;
|
||||
org.isPublic = isPublic ?? false;
|
||||
org.memberCount = 1;
|
||||
org.createdAt = new Date();
|
||||
org.createdById = ctx.actor.userId;
|
||||
|
||||
await org.save();
|
||||
|
||||
// Add creator as owner
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = ctx.actor.userId;
|
||||
membership.role = 'owner';
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).logOrganizationCreated(org.id, org.name);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
isPublic: org.isPublic,
|
||||
createdAt: org.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
|
||||
|
||||
if (displayName !== undefined) org.displayName = displayName;
|
||||
if (description !== undefined) org.description = description;
|
||||
if (avatarUrl !== undefined) org.avatarUrl = avatarUrl;
|
||||
if (website !== undefined) org.website = website;
|
||||
if (isPublic !== undefined) org.isPublic = isPublic;
|
||||
|
||||
// Only system admins can change settings
|
||||
if (settings && ctx.actor.user?.isSystemAdmin) {
|
||||
org.settings = { ...org.settings, ...settings };
|
||||
}
|
||||
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// TODO: Check for packages, repositories before deletion
|
||||
// For now, just delete the organization and memberships
|
||||
await org.delete();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Organization deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete organization' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id/members
|
||||
*/
|
||||
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await OrganizationMember.getOrgMembers(id);
|
||||
|
||||
// Fetch user details
|
||||
const membersWithUsers = await Promise.all(
|
||||
members.map(async (m) => {
|
||||
const user = await User.findById(m.userId);
|
||||
return {
|
||||
userId: m.userId,
|
||||
role: m.role,
|
||||
addedAt: m.addedAt,
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { members: membersWithUsers },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] List members error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list members' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:id/members
|
||||
*/
|
||||
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { userId, role } = body as { userId: string; role: TOrganizationRole };
|
||||
|
||||
if (!userId || !role) {
|
||||
return { status: 400, body: { error: 'userId and role are required' } };
|
||||
}
|
||||
|
||||
if (!['owner', 'admin', 'member'].includes(role)) {
|
||||
return { status: 400, body: { error: 'Invalid role' } };
|
||||
}
|
||||
|
||||
// Check user exists
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existing = await OrganizationMember.findMembership(id, userId);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'User is already a member' } };
|
||||
}
|
||||
|
||||
// Add member
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
userId: membership.userId,
|
||||
role: membership.role,
|
||||
addedAt: membership.addedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Add member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to add member' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id/members/:userId
|
||||
*/
|
||||
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { role } = body as { role: TOrganizationRole };
|
||||
|
||||
if (!role || !['owner', 'admin', 'member'].includes(role)) {
|
||||
return { status: 400, body: { error: 'Valid role is required' } };
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot change last owner
|
||||
if (membership.role === 'owner' && role !== 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
}
|
||||
}
|
||||
|
||||
membership.role = role;
|
||||
await membership.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
userId: membership.userId,
|
||||
role: membership.role,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Update member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update member' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id/members/:userId
|
||||
*/
|
||||
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot remove last owner
|
||||
if (membership.role === 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
}
|
||||
}
|
||||
|
||||
await membership.delete();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Member removed successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OrganizationApi] Remove member error:', error);
|
||||
return { status: 500, body: { error: 'Failed to remove member' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
321
ts/api/handlers/package.api.ts
Normal file
321
ts/api/handlers/package.api.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Package API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { Package, Repository } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class PackageApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages (search)
|
||||
*/
|
||||
public async search(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const query = ctx.url.searchParams.get('q') || '';
|
||||
const protocol = ctx.url.searchParams.get('protocol') as TRegistryProtocol | undefined;
|
||||
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
||||
const limit = parseInt(ctx.url.searchParams.get('limit') || '50', 10);
|
||||
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
// For authenticated users, search includes private packages they have access to
|
||||
// For anonymous users, only search public packages
|
||||
const isPrivate = ctx.actor?.userId ? undefined : false;
|
||||
|
||||
const packages = await Package.search(query, {
|
||||
protocol,
|
||||
organizationId,
|
||||
isPrivate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
// Filter out packages user doesn't have access to
|
||||
const accessiblePackages = [];
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.isPrivate) {
|
||||
accessiblePackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ctx.actor?.userId) {
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
if (canAccess) {
|
||||
accessiblePackages.push(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
packages: accessiblePackages.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
latestVersion: pkg.distTags['latest'],
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount,
|
||||
updatedAt: pkg.updatedAt,
|
||||
})),
|
||||
total: accessiblePackages.length,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Search error:', error);
|
||||
return { status: 500, body: { error: 'Failed to search packages' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (pkg.isPrivate) {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
distTags: pkg.distTags,
|
||||
versions: Object.keys(pkg.versions),
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount,
|
||||
starCount: pkg.starCount,
|
||||
storageBytes: pkg.storageBytes,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get package' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/packages/:id/versions
|
||||
*/
|
||||
public async listVersions(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (pkg.isPrivate) {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
const versions = Object.entries(pkg.versions).map(([version, data]) => ({
|
||||
version,
|
||||
publishedAt: data.publishedAt,
|
||||
size: data.size,
|
||||
downloads: data.downloads,
|
||||
checksum: data.checksum,
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
packageId: pkg.id,
|
||||
packageName: pkg.name,
|
||||
distTags: pkg.distTags,
|
||||
versions,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] List versions error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list versions' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/packages/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
return { status: 403, body: { error: 'Delete permission required' } };
|
||||
}
|
||||
|
||||
// Delete the package
|
||||
await pkg.delete();
|
||||
|
||||
// Update repository package count
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.packageCount = Math.max(0, repo.packageCount - 1);
|
||||
repo.storageBytes -= pkg.storageBytes;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Package deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete package' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/packages/:id/versions/:version
|
||||
*/
|
||||
public async deleteVersion(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id, version } = ctx.params;
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(decodeURIComponent(id));
|
||||
if (!pkg) {
|
||||
return { status: 404, body: { error: 'Package not found' } };
|
||||
}
|
||||
|
||||
const versionData = pkg.versions[version];
|
||||
if (!versionData) {
|
||||
return { status: 404, body: { error: 'Version not found' } };
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
return { status: 403, body: { error: 'Delete permission required' } };
|
||||
}
|
||||
|
||||
// Check if this is the only version
|
||||
if (Object.keys(pkg.versions).length === 1) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Cannot delete the only version. Delete the entire package instead.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Remove version
|
||||
const sizeReduction = versionData.size;
|
||||
delete pkg.versions[version];
|
||||
pkg.storageBytes -= sizeReduction;
|
||||
|
||||
// Update dist tags
|
||||
for (const [tag, tagVersion] of Object.entries(pkg.distTags)) {
|
||||
if (tagVersion === version) {
|
||||
delete pkg.distTags[tag];
|
||||
}
|
||||
}
|
||||
|
||||
// Set new latest if needed
|
||||
if (!pkg.distTags['latest'] && Object.keys(pkg.versions).length > 0) {
|
||||
const versions = Object.keys(pkg.versions).sort();
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
}
|
||||
|
||||
await pkg.save();
|
||||
|
||||
// Update repository storage
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.storageBytes -= sizeReduction;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Version deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PackageApi] Delete version error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete version' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
293
ts/api/handlers/repository.api.ts
Normal file
293
ts/api/handlers/repository.api.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Repository API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import { Repository, Organization } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class RepositoryApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:orgId/repositories
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { orgId } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get accessible repositories
|
||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||
ctx.actor.userId,
|
||||
orgId
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
repositories: repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
createdAt: repo.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list repositories' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/repositories/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (!repo.isPublic && ctx.actor?.userId) {
|
||||
const permissions = await this.permissionService.resolvePermissions({
|
||||
userId: ctx.actor.userId,
|
||||
organizationId: repo.organizationId,
|
||||
repositoryId: repo.id,
|
||||
});
|
||||
|
||||
if (!permissions.canRead) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes,
|
||||
createdAt: repo.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:orgId/repositories
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { orgId } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, orgId);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, displayName, description, protocols, isPublic, settings } = body;
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Repository name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check org exists
|
||||
const org = await Organization.findById(orgId);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check if name is taken in this org
|
||||
const existing = await Repository.findByName(orgId, name);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Repository name already taken in this organization' } };
|
||||
}
|
||||
|
||||
// Create repository
|
||||
const repo = new Repository();
|
||||
repo.id = await Repository.getNewId();
|
||||
repo.organizationId = orgId;
|
||||
repo.name = name;
|
||||
repo.displayName = displayName || name;
|
||||
repo.description = description;
|
||||
repo.protocols = protocols || ['npm'];
|
||||
repo.isPublic = isPublic ?? false;
|
||||
repo.settings = settings || {
|
||||
allowOverwrite: false,
|
||||
immutableTags: false,
|
||||
retentionDays: 0,
|
||||
};
|
||||
repo.createdAt = new Date();
|
||||
repo.createdById = ctx.actor.userId;
|
||||
|
||||
await repo.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
organizationId: orgId,
|
||||
}).logRepositoryCreated(repo.id, repo.name, orgId);
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
createdAt: repo.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/repositories/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, protocols, isPublic, settings } = body;
|
||||
|
||||
if (displayName !== undefined) repo.displayName = displayName;
|
||||
if (description !== undefined) repo.description = description;
|
||||
if (protocols !== undefined) repo.protocols = protocols;
|
||||
if (isPublic !== undefined) repo.isPublic = isPublic;
|
||||
if (settings !== undefined) repo.settings = { ...repo.settings, ...settings };
|
||||
|
||||
await repo.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update repository' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/repositories/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(id);
|
||||
if (!repo) {
|
||||
return { status: 404, body: { error: 'Repository not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
// Check for packages
|
||||
if (repo.packageCount > 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Cannot delete repository with packages. Remove all packages first.' },
|
||||
};
|
||||
}
|
||||
|
||||
await repo.delete();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Repository deleted successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RepositoryApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete repository' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
157
ts/api/handlers/token.api.ts
Normal file
157
ts/api/handlers/token.api.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Token API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { TokenService } from '../../services/token.service.ts';
|
||||
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class TokenApi {
|
||||
private tokenService: TokenService;
|
||||
|
||||
constructor(tokenService: TokenService) {
|
||||
this.tokenService = tokenService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/tokens
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
tokens: tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
protocols: t.protocols,
|
||||
scopes: t.scopes,
|
||||
expiresAt: t.expiresAt,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
usageCount: t.usageCount,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list tokens' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/tokens
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, protocols, scopes, expiresInDays } = body as {
|
||||
name: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresInDays?: number;
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Token name is required' } };
|
||||
}
|
||||
|
||||
if (!protocols || protocols.length === 0) {
|
||||
return { status: 400, body: { error: 'At least one protocol is required' } };
|
||||
}
|
||||
|
||||
if (!scopes || scopes.length === 0) {
|
||||
return { status: 400, body: { error: 'At least one scope is required' } };
|
||||
}
|
||||
|
||||
// Validate protocols
|
||||
const validProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems', '*'];
|
||||
for (const protocol of protocols) {
|
||||
if (!validProtocols.includes(protocol)) {
|
||||
return { status: 400, body: { error: `Invalid protocol: ${protocol}` } };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
for (const scope of scopes) {
|
||||
if (!scope.protocol || !scope.actions || scope.actions.length === 0) {
|
||||
return { status: 400, body: { error: 'Invalid scope configuration' } };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.tokenService.createToken({
|
||||
userId: ctx.actor.userId,
|
||||
name,
|
||||
protocols,
|
||||
scopes,
|
||||
expiresInDays,
|
||||
createdIp: ctx.ip,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: result.token.id,
|
||||
name: result.token.name,
|
||||
token: result.rawToken, // Only returned once!
|
||||
tokenPrefix: result.token.tokenPrefix,
|
||||
protocols: result.token.protocols,
|
||||
scopes: result.token.scopes,
|
||||
expiresAt: result.token.expiresAt,
|
||||
createdAt: result.token.createdAt,
|
||||
warning: 'Store this token securely. It will not be shown again.',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create token' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/tokens/:id
|
||||
*/
|
||||
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get the token to verify ownership
|
||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
const token = tokens.find((t) => t.id === id);
|
||||
|
||||
if (!token) {
|
||||
// Either doesn't exist or doesn't belong to user
|
||||
return { status: 404, body: { error: 'Token not found' } };
|
||||
}
|
||||
|
||||
const success = await this.tokenService.revokeToken(id, 'user_revoked');
|
||||
|
||||
if (!success) {
|
||||
return { status: 500, body: { error: 'Failed to revoke token' } };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Token revoked successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TokenApi] Revoke error:', error);
|
||||
return { status: 500, body: { error: 'Failed to revoke token' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
260
ts/api/handlers/user.api.ts
Normal file
260
ts/api/handlers/user.api.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* User API handlers
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
import { User } from '../../models/user.ts';
|
||||
|
||||
export class UserApi {
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(permissionService: PermissionService) {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can list all users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await User.getInstances({});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
isSystemAdmin: u.isSystemAdmin,
|
||||
isActive: u.isActive,
|
||||
createdAt: u.createdAt,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] List error:', error);
|
||||
return { status: 500, body: { error: 'Failed to list users' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/:id
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Users can view their own profile, admins can view any
|
||||
if (id !== ctx.actor.userId && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Get error:', error);
|
||||
return { status: 500, body: { error: 'Failed to get user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users
|
||||
*/
|
||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can create users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { email, username, password, displayName, isSystemAdmin } = body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Email, username, and password are required' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await User.findByEmail(email);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Email already in use' } };
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await User.findByUsername(username);
|
||||
if (existingUsername) {
|
||||
return { status: 409, body: { error: 'Username already in use' } };
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await AuthService.hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = new User();
|
||||
user.id = await User.getNewId();
|
||||
user.email = email;
|
||||
user.username = username;
|
||||
user.passwordHash = passwordHash;
|
||||
user.displayName = displayName || username;
|
||||
user.isSystemAdmin = isSystemAdmin || false;
|
||||
user.isActive = true;
|
||||
user.createdAt = new Date();
|
||||
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Create error:', error);
|
||||
return { status: 500, body: { error: 'Failed to create user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/users/:id
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Users can update their own profile, admins can update any
|
||||
if (id !== ctx.actor.userId && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, avatarUrl, password, isActive, isSystemAdmin } = body;
|
||||
|
||||
if (displayName !== undefined) user.displayName = displayName;
|
||||
if (avatarUrl !== undefined) user.avatarUrl = avatarUrl;
|
||||
|
||||
// Only admins can change these
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
if (isActive !== undefined) user.isActive = isActive;
|
||||
if (isSystemAdmin !== undefined) user.isSystemAdmin = isSystemAdmin;
|
||||
}
|
||||
|
||||
// Password change
|
||||
if (password) {
|
||||
user.passwordHash = await AuthService.hashPassword(password);
|
||||
}
|
||||
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Update error:', error);
|
||||
return { status: 500, body: { error: 'Failed to update user' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/users/:id
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
return { status: 401, body: { error: 'Authentication required' } };
|
||||
}
|
||||
|
||||
// Only system admins can delete users
|
||||
if (!ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'System admin access required' } };
|
||||
}
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Cannot delete yourself
|
||||
if (id === ctx.actor.userId) {
|
||||
return { status: 400, body: { error: 'Cannot delete your own account' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(id);
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
// Soft delete - deactivate instead of removing
|
||||
user.isActive = false;
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'User deactivated successfully' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[UserApi] Delete error:', error);
|
||||
return { status: 500, body: { error: 'Failed to delete user' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
6
ts/api/index.ts
Normal file
6
ts/api/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* API exports
|
||||
*/
|
||||
|
||||
export { ApiRouter, type IApiContext, type IApiResponse } from './router.ts';
|
||||
export * from './handlers/index.ts';
|
||||
277
ts/api/router.ts
Normal file
277
ts/api/router.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* API Router - Routes REST API requests to appropriate handlers
|
||||
*/
|
||||
|
||||
import type { IStackGalleryActor } from '../providers/auth.provider.ts';
|
||||
import { AuthService } from '../services/auth.service.ts';
|
||||
import { TokenService } from '../services/token.service.ts';
|
||||
import { PermissionService } from '../services/permission.service.ts';
|
||||
import { AuditService } from '../services/audit.service.ts';
|
||||
|
||||
// Import API handlers
|
||||
import { AuthApi } from './handlers/auth.api.ts';
|
||||
import { UserApi } from './handlers/user.api.ts';
|
||||
import { OrganizationApi } from './handlers/organization.api.ts';
|
||||
import { RepositoryApi } from './handlers/repository.api.ts';
|
||||
import { PackageApi } from './handlers/package.api.ts';
|
||||
import { TokenApi } from './handlers/token.api.ts';
|
||||
import { AuditApi } from './handlers/audit.api.ts';
|
||||
|
||||
export interface IApiContext {
|
||||
request: Request;
|
||||
url: URL;
|
||||
path: string;
|
||||
method: string;
|
||||
params: Record<string, string>;
|
||||
actor?: IStackGalleryActor;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface IApiResponse {
|
||||
status: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: IApiContext) => Promise<IApiResponse>;
|
||||
|
||||
interface IRoute {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
export class ApiRouter {
|
||||
private routes: IRoute[] = [];
|
||||
private authService: AuthService;
|
||||
private tokenService: TokenService;
|
||||
private permissionService: PermissionService;
|
||||
|
||||
// API handlers
|
||||
private authApi: AuthApi;
|
||||
private userApi: UserApi;
|
||||
private organizationApi: OrganizationApi;
|
||||
private repositoryApi: RepositoryApi;
|
||||
private packageApi: PackageApi;
|
||||
private tokenApi: TokenApi;
|
||||
private auditApi: AuditApi;
|
||||
|
||||
constructor() {
|
||||
this.authService = new AuthService();
|
||||
this.tokenService = new TokenService();
|
||||
this.permissionService = new PermissionService();
|
||||
|
||||
// Initialize API handlers
|
||||
this.authApi = new AuthApi(this.authService);
|
||||
this.userApi = new UserApi(this.permissionService);
|
||||
this.organizationApi = new OrganizationApi(this.permissionService);
|
||||
this.repositoryApi = new RepositoryApi(this.permissionService);
|
||||
this.packageApi = new PackageApi(this.permissionService);
|
||||
this.tokenApi = new TokenApi(this.tokenService);
|
||||
this.auditApi = new AuditApi(this.permissionService);
|
||||
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all API routes
|
||||
*/
|
||||
private registerRoutes(): void {
|
||||
// Auth routes
|
||||
this.addRoute('POST', '/api/v1/auth/login', (ctx) => this.authApi.login(ctx));
|
||||
this.addRoute('POST', '/api/v1/auth/refresh', (ctx) => this.authApi.refresh(ctx));
|
||||
this.addRoute('POST', '/api/v1/auth/logout', (ctx) => this.authApi.logout(ctx));
|
||||
this.addRoute('GET', '/api/v1/auth/me', (ctx) => this.authApi.me(ctx));
|
||||
|
||||
// User routes
|
||||
this.addRoute('GET', '/api/v1/users', (ctx) => this.userApi.list(ctx));
|
||||
this.addRoute('GET', '/api/v1/users/:id', (ctx) => this.userApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/users', (ctx) => this.userApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/users/:id', (ctx) => this.userApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/users/:id', (ctx) => this.userApi.delete(ctx));
|
||||
|
||||
// Organization routes
|
||||
this.addRoute('GET', '/api/v1/organizations', (ctx) => this.organizationApi.list(ctx));
|
||||
this.addRoute('GET', '/api/v1/organizations/:id', (ctx) => this.organizationApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations', (ctx) => this.organizationApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/organizations/:id', (ctx) => this.organizationApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/organizations/:id', (ctx) => this.organizationApi.delete(ctx));
|
||||
this.addRoute('GET', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.listMembers(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.addMember(ctx));
|
||||
this.addRoute('PUT', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.updateMember(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.removeMember(ctx));
|
||||
|
||||
// Repository routes
|
||||
this.addRoute('GET', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.list(ctx));
|
||||
this.addRoute('GET', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
|
||||
|
||||
// Package routes
|
||||
this.addRoute('GET', '/api/v1/packages', (ctx) => this.packageApi.search(ctx));
|
||||
this.addRoute('GET', '/api/v1/packages/:id', (ctx) => this.packageApi.get(ctx));
|
||||
this.addRoute('GET', '/api/v1/packages/:id/versions', (ctx) => this.packageApi.listVersions(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/packages/:id', (ctx) => this.packageApi.delete(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/packages/:id/versions/:version', (ctx) => this.packageApi.deleteVersion(ctx));
|
||||
|
||||
// Token routes
|
||||
this.addRoute('GET', '/api/v1/tokens', (ctx) => this.tokenApi.list(ctx));
|
||||
this.addRoute('POST', '/api/v1/tokens', (ctx) => this.tokenApi.create(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/tokens/:id', (ctx) => this.tokenApi.revoke(ctx));
|
||||
|
||||
// Audit routes
|
||||
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a route
|
||||
*/
|
||||
private addRoute(method: string, path: string, handler: RouteHandler): void {
|
||||
const paramNames: string[] = [];
|
||||
const patternStr = path.replace(/:(\w+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
return '([^/]+)';
|
||||
});
|
||||
const pattern = new RegExp(`^${patternStr}$`);
|
||||
|
||||
this.routes.push({ method, pattern, paramNames, handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an API request
|
||||
*/
|
||||
public async handle(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract params
|
||||
const params: Record<string, string> = {};
|
||||
route.paramNames.forEach((name, i) => {
|
||||
params[name] = match[i + 1];
|
||||
});
|
||||
|
||||
// Build context
|
||||
const ctx: IApiContext = {
|
||||
request,
|
||||
url,
|
||||
path,
|
||||
method,
|
||||
params,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
};
|
||||
|
||||
// Authenticate request (except for login)
|
||||
if (!path.includes('/auth/login')) {
|
||||
ctx.actor = await this.authenticateRequest(request);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await route.handler(ctx);
|
||||
return this.buildResponse(result);
|
||||
} catch (error) {
|
||||
console.error('[ApiRouter] Handler error:', error);
|
||||
return this.buildResponse({
|
||||
status: 500,
|
||||
body: { error: 'Internal server error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildResponse({
|
||||
status: 404,
|
||||
body: { error: 'Not found' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate request from headers
|
||||
*/
|
||||
private async authenticateRequest(request: Request): Promise<IStackGalleryActor | undefined> {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
|
||||
// Try Bearer token (JWT)
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// Check if it's a JWT (for UI) or API token
|
||||
if (token.startsWith('srg_')) {
|
||||
// API token
|
||||
const result = await this.tokenService.validateToken(token, this.getClientIp(request));
|
||||
if (result.valid && result.token && result.user) {
|
||||
return {
|
||||
type: 'api_token',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
tokenId: result.token.id,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
protocols: result.token.protocols,
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// JWT token
|
||||
const result = await this.authService.validateAccessToken(token);
|
||||
if (result) {
|
||||
return {
|
||||
type: 'user',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
ip: this.getClientIp(request),
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP from request
|
||||
*/
|
||||
private getClientIp(request: Request): string {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTTP response from API response
|
||||
*/
|
||||
private buildResponse(result: IApiResponse): Response {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...result.headers,
|
||||
};
|
||||
|
||||
return new Response(result.body ? JSON.stringify(result.body) : null, {
|
||||
status: result.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
108
ts/cli.ts
Normal file
108
ts/cli.ts
Normal file
@@ -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<void> {
|
||||
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 <command> [options]
|
||||
|
||||
Commands:
|
||||
server [--ephemeral] [--monitor] Start the registry server
|
||||
status Check registry status
|
||||
user <subcommand> User management
|
||||
org <subcommand> 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();
|
||||
}
|
||||
19
ts/index.ts
Normal file
19
ts/index.ts
Normal file
@@ -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';
|
||||
152
ts/interfaces/audit.interfaces.ts
Normal file
152
ts/interfaces/audit.interfaces.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
success?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Token Activity
|
||||
// =============================================================================
|
||||
|
||||
export interface ITokenActivitySummary {
|
||||
tokenId: string;
|
||||
totalActions: number;
|
||||
lastUsed?: Date;
|
||||
actionBreakdown: Record<string, number>;
|
||||
ipAddresses: string[];
|
||||
}
|
||||
282
ts/interfaces/auth.interfaces.ts
Normal file
282
ts/interfaces/auth.interfaces.ts
Normal file
@@ -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;
|
||||
}
|
||||
7
ts/interfaces/index.ts
Normal file
7
ts/interfaces/index.ts
Normal file
@@ -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';
|
||||
202
ts/interfaces/package.interfaces.ts
Normal file
202
ts/interfaces/package.interfaces.ts
Normal file
@@ -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<string, IPackageVersion>;
|
||||
distTags: Record<string, string>; // 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<string, string>;
|
||||
}
|
||||
|
||||
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<string, string[]>;
|
||||
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<string, string>;
|
||||
requireDev?: Record<string, string>;
|
||||
autoload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IPypiMetadata {
|
||||
type: 'pypi';
|
||||
classifiers?: string[];
|
||||
requiresPython?: string;
|
||||
requiresDist?: string[];
|
||||
providesExtra?: string[];
|
||||
projectUrls?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
engines?: Record<string, string>;
|
||||
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<string, number>;
|
||||
downloadsByDay: Array<{ date: string; count: number }>;
|
||||
downloadsByCountry?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface IOrganizationStats {
|
||||
organizationId: string;
|
||||
totalPackages: number;
|
||||
totalDownloads: number;
|
||||
storageUsedBytes: number;
|
||||
storageQuotaBytes: number;
|
||||
packagesByProtocol: Record<TRegistryProtocol, number>;
|
||||
}
|
||||
167
ts/models/apitoken.ts
Normal file
167
ts/models/apitoken.ts
Normal file
@@ -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<ApiToken, ApiToken>
|
||||
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<ApiToken | null> {
|
||||
return await ApiToken.getInstance({
|
||||
tokenHash,
|
||||
isRevoked: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find token by prefix (for listing)
|
||||
*/
|
||||
public static async findByPrefix(tokenPrefix: string): Promise<ApiToken | null> {
|
||||
return await ApiToken.getInstance({
|
||||
tokenPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tokens for a user
|
||||
*/
|
||||
public static async getUserTokens(userId: string): Promise<ApiToken[]> {
|
||||
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<void> {
|
||||
this.lastUsedAt = new Date();
|
||||
this.lastUsedIp = ip;
|
||||
this.usageCount += 1;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke token
|
||||
*/
|
||||
public async revoke(reason?: string): Promise<void> {
|
||||
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<void> {
|
||||
if (!this.id) {
|
||||
this.id = await ApiToken.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
171
ts/models/auditlog.ts
Normal file
171
ts/models/auditlog.ts
Normal file
@@ -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<AuditLog, AuditLog>
|
||||
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<string, unknown> = {};
|
||||
|
||||
@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<string, unknown>;
|
||||
success?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
}): Promise<AuditLog> {
|
||||
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<string, unknown> = {};
|
||||
|
||||
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<string, unknown>).$gte = filters.startDate;
|
||||
if (filters.endDate) (query.timestamp as Record<string, unknown>).$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<void> {
|
||||
if (!this.id) {
|
||||
this.id = await AuditLog.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
57
ts/models/db.ts
Normal file
57
ts/models/db.ts
Normal file
@@ -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<plugins.smartdata.SmartdataDb> {
|
||||
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<void> {
|
||||
if (dbInstance) {
|
||||
await dbInstance.close();
|
||||
dbInstance = null;
|
||||
console.log('Database connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database is connected
|
||||
*/
|
||||
export function isDbConnected(): boolean {
|
||||
return dbInstance !== null;
|
||||
}
|
||||
16
ts/models/index.ts
Normal file
16
ts/models/index.ts
Normal file
@@ -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';
|
||||
109
ts/models/organization.member.ts
Normal file
109
ts/models/organization.member.ts
Normal file
@@ -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<OrganizationMember, OrganizationMember>
|
||||
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<OrganizationMember> {
|
||||
// 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<OrganizationMember | null> {
|
||||
return await OrganizationMember.getInstance({
|
||||
organizationId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of an organization
|
||||
*/
|
||||
public static async getOrgMembers(organizationId: string): Promise<OrganizationMember[]> {
|
||||
return await OrganizationMember.getInstances({
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all organizations a user belongs to
|
||||
*/
|
||||
public static async getUserOrganizations(userId: string): Promise<OrganizationMember[]> {
|
||||
return await OrganizationMember.getInstances({
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await OrganizationMember.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
138
ts/models/organization.ts
Normal file
138
ts/models/organization.ts
Normal file
@@ -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<Organization, Organization>
|
||||
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<Organization> {
|
||||
// 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<Organization | null> {
|
||||
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<void> {
|
||||
this.usedStorageBytes = Math.max(0, this.usedStorageBytes + deltaBytes);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Update timestamps before save
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await Organization.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
195
ts/models/package.ts
Normal file
195
ts/models/package.ts
Normal file
@@ -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<Package, Package> 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<string, IPackageVersion> = {};
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public distTags: Record<string, string> = {}; // 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<Package | null> {
|
||||
return await Package.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find package by protocol, org, and name
|
||||
*/
|
||||
public static async findByName(
|
||||
protocol: TRegistryProtocol,
|
||||
orgName: string,
|
||||
name: string
|
||||
): Promise<Package | null> {
|
||||
const id = Package.generateId(protocol, orgName, name);
|
||||
return await Package.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get packages in an organization
|
||||
*/
|
||||
public static async getOrgPackages(organizationId: string): Promise<Package[]> {
|
||||
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<Package[]> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
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<void> {
|
||||
this.downloadCount += 1;
|
||||
if (version && this.versions[version]) {
|
||||
this.versions[version].downloads += 1;
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await Package.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
162
ts/models/repository.permission.ts
Normal file
162
ts/models/repository.permission.ts
Normal file
@@ -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<RepositoryPermission, RepositoryPermission>
|
||||
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<RepositoryPermission> {
|
||||
// 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<RepositoryPermission> {
|
||||
// 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<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getInstance({
|
||||
repositoryId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team's permission on repository
|
||||
*/
|
||||
public static async getTeamPermission(
|
||||
repositoryId: string,
|
||||
teamId: string
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getInstance({
|
||||
repositoryId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a repository
|
||||
*/
|
||||
public static async getRepoPermissions(repositoryId: string): Promise<RepositoryPermission[]> {
|
||||
return await RepositoryPermission.getInstances({
|
||||
repositoryId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for user's teams on a repository
|
||||
*/
|
||||
public static async getTeamPermissionsForRepo(
|
||||
repositoryId: string,
|
||||
teamIds: string[]
|
||||
): Promise<RepositoryPermission[]> {
|
||||
if (teamIds.length === 0) return [];
|
||||
return await RepositoryPermission.getInstances({
|
||||
repositoryId,
|
||||
teamId: { $in: teamIds } as unknown as string,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await RepositoryPermission.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
158
ts/models/repository.ts
Normal file
158
ts/models/repository.ts
Normal file
@@ -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<Repository, Repository>
|
||||
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<Repository> {
|
||||
// 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<Repository | null> {
|
||||
return await Repository.getInstance({
|
||||
organizationId,
|
||||
name: name.toLowerCase(),
|
||||
protocol,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all repositories in an organization
|
||||
*/
|
||||
public static async getOrgRepositories(organizationId: string): Promise<Repository[]> {
|
||||
return await Repository.getInstances({
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all public repositories
|
||||
*/
|
||||
public static async getPublicRepositories(protocol?: TRegistryProtocol): Promise<Repository[]> {
|
||||
const query: Record<string, unknown> = { visibility: 'public' };
|
||||
if (protocol) {
|
||||
query.protocol = protocol;
|
||||
}
|
||||
return await Repository.getInstances(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment download count
|
||||
*/
|
||||
public async incrementDownloads(): Promise<void> {
|
||||
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<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await Repository.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
135
ts/models/session.ts
Normal file
135
ts/models/session.ts
Normal file
@@ -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<Session, Session>
|
||||
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<Session> {
|
||||
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<Session | null> {
|
||||
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<Session[]> {
|
||||
return await Session.getInstances({
|
||||
userId,
|
||||
isValid: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all sessions for a user
|
||||
*/
|
||||
public static async invalidateAllUserSessions(
|
||||
userId: string,
|
||||
reason: string = 'logout_all'
|
||||
): Promise<number> {
|
||||
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<void> {
|
||||
this.isValid = false;
|
||||
this.invalidatedAt = new Date();
|
||||
this.invalidatedReason = reason;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activity
|
||||
*/
|
||||
public async touchActivity(): Promise<void> {
|
||||
this.lastActivityAt = new Date();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await Session.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
97
ts/models/team.member.ts
Normal file
97
ts/models/team.member.ts
Normal file
@@ -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<TeamMember, TeamMember>
|
||||
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<TeamMember> {
|
||||
// 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<TeamMember | null> {
|
||||
return await TeamMember.getInstance({
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of a team
|
||||
*/
|
||||
public static async getTeamMembers(teamId: string): Promise<TeamMember[]> {
|
||||
return await TeamMember.getInstances({
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all teams a user belongs to
|
||||
*/
|
||||
public static async getUserTeams(userId: string): Promise<TeamMember[]> {
|
||||
return await TeamMember.getInstances({
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await TeamMember.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
100
ts/models/team.ts
Normal file
100
ts/models/team.ts
Normal file
@@ -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<Team, Team> 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<Team> {
|
||||
// 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<Team | null> {
|
||||
return await Team.getInstance({
|
||||
organizationId,
|
||||
name: name.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all teams in an organization
|
||||
*/
|
||||
public static async getOrgTeams(organizationId: string): Promise<Team[]> {
|
||||
return await Team.getInstances({
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await Team.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
115
ts/models/user.ts
Normal file
115
ts/models/user.ts
Normal file
@@ -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<User, User> 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<User> {
|
||||
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<User | null> {
|
||||
return await User.getInstance({ email: email.toLowerCase() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by username
|
||||
*/
|
||||
public static async findByUsername(username: string): Promise<User | null> {
|
||||
return await User.getInstance({ username: username.toLowerCase() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Update timestamps before save
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await User.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
52
ts/plugins.ts
Normal file
52
ts/plugins.ts
Normal file
@@ -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,
|
||||
};
|
||||
277
ts/providers/auth.provider.ts
Normal file
277
ts/providers/auth.provider.ts
Normal file
@@ -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<plugins.smartregistry.IRequestActor> {
|
||||
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<plugins.smartregistry.IAuthorizationResult> {
|
||||
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<IStackGalleryActor> {
|
||||
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<IStackGalleryActor> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
6
ts/providers/index.ts
Normal file
6
ts/providers/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Provider exports
|
||||
*/
|
||||
|
||||
export { StackGalleryAuthProvider, type IStackGalleryActor } from './auth.provider.ts';
|
||||
export { StackGalleryStorageHooks, type IStorageConfig } from './storage.provider.ts';
|
||||
297
ts/providers/storage.provider.ts
Normal file
297
ts/providers/storage.provider.ts
Normal file
@@ -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<plugins.smartregistry.IStorageContext> {
|
||||
// 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<void> {
|
||||
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<plugins.smartregistry.IFetchContext> {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is fetched
|
||||
* Update download metrics
|
||||
*/
|
||||
public async afterFetch(context: plugins.smartregistry.IFetchContext): Promise<void> {
|
||||
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<plugins.smartregistry.IDeleteContext> {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is deleted
|
||||
*/
|
||||
public async afterDelete(context: plugins.smartregistry.IDeleteContext): Promise<void> {
|
||||
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<string> {
|
||||
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<Uint8Array | null> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
await bucket.fastDelete({ path });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
276
ts/registry.ts
Normal file
276
ts/registry.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<Response> => {
|
||||
return await this.handleRequest(request);
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP request
|
||||
*/
|
||||
private async handleRequest(request: Request): Promise<Response> {
|
||||
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<Response> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
197
ts/services/audit.service.ts
Normal file
197
ts/services/audit.service.ts
Normal file
@@ -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<string, unknown>;
|
||||
success?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
} = {}
|
||||
): Promise<AuditLog> {
|
||||
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<string, unknown>
|
||||
): Promise<AuditLog> {
|
||||
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<string, unknown>
|
||||
): Promise<AuditLog> {
|
||||
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<AuditLog> {
|
||||
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<AuditLog> {
|
||||
return await this.logSuccess('USER_LOGOUT', 'user', userId);
|
||||
}
|
||||
|
||||
public async logTokenCreated(tokenId: string, tokenName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('TOKEN_CREATED', 'api_token', tokenId, tokenName);
|
||||
}
|
||||
|
||||
public async logTokenRevoked(tokenId: string, tokenName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('TOKEN_REVOKED', 'api_token', tokenId, tokenName);
|
||||
}
|
||||
|
||||
public async logPackagePublished(
|
||||
packageId: string,
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<AuditLog> {
|
||||
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<AuditLog> {
|
||||
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<AuditLog> {
|
||||
return await this.logSuccess('ORGANIZATION_CREATED', 'organization', orgId, orgName);
|
||||
}
|
||||
|
||||
public async logRepositoryCreated(
|
||||
repoId: string,
|
||||
repoName: string,
|
||||
organizationId: string
|
||||
): Promise<AuditLog> {
|
||||
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<AuditLog> {
|
||||
return await this.log('PERMISSION_CHANGED', resourceType, {
|
||||
resourceId,
|
||||
metadata: {
|
||||
targetUserId,
|
||||
oldRole,
|
||||
newRole,
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
405
ts/services/auth.service.ts
Normal file
405
ts/services/auth.service.ts
Normal file
@@ -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<IAuthConfig> = {}) {
|
||||
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<IAuthResult> {
|
||||
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<IAuthResult> {
|
||||
// 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<boolean> {
|
||||
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<number> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<IJwtPayload | null> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
22
ts/services/index.ts
Normal file
22
ts/services/index.ts
Normal file
@@ -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';
|
||||
307
ts/services/permission.service.ts
Normal file
307
ts/services/permission.service.ts
Normal file
@@ -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<IResolvedPermissions> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
return await this.checkPermission(
|
||||
{ userId, organizationId, repositoryId },
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can manage organization
|
||||
*/
|
||||
public async canManageOrganization(userId: string, organizationId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<Repository[]> {
|
||||
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<TRepositoryRole, number> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
209
ts/services/token.service.ts
Normal file
209
ts/services/token.service.ts
Normal file
@@ -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<ITokenValidationResult> {
|
||||
// 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<ApiToken[]> {
|
||||
return await ApiToken.getUserTokens(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token
|
||||
*/
|
||||
public async revokeToken(tokenId: string, reason?: string): Promise<boolean> {
|
||||
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<number> {
|
||||
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<string> {
|
||||
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}...`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user