feat: implement account settings and API tokens management

- Added SettingsComponent for user profile management, including display name and password change functionality.
- Introduced TokensComponent for managing API tokens, including creation and revocation.
- Created LayoutComponent for consistent application layout with navigation and user information.
- Established main application structure in index.html and main.ts.
- Integrated Tailwind CSS for styling and responsive design.
- Configured TypeScript settings for strict type checking and module resolution.
This commit is contained in:
2025-11-27 22:15:38 +00:00
parent a6c6ea1393
commit ab88ac896f
71 changed files with 9446 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
/**
* Audit API handlers
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditLog } from '../../models/auditlog.ts';
import type { TAuditAction, TAuditResourceType } from '../../interfaces/audit.interfaces.ts';
export class AuditApi {
private permissionService: PermissionService;
constructor(permissionService: PermissionService) {
this.permissionService = permissionService;
}
/**
* GET /api/v1/audit
*/
public async query(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
try {
// Parse query parameters
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
const repositoryId = ctx.url.searchParams.get('repositoryId') || undefined;
const resourceType = ctx.url.searchParams.get('resourceType') as TAuditResourceType | undefined;
const actionsParam = ctx.url.searchParams.get('actions');
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
const success = ctx.url.searchParams.has('success')
? ctx.url.searchParams.get('success') === 'true'
: undefined;
const startDateParam = ctx.url.searchParams.get('startDate');
const endDateParam = ctx.url.searchParams.get('endDate');
const startDate = startDateParam ? new Date(startDateParam) : undefined;
const endDate = endDateParam ? new Date(endDateParam) : undefined;
const limit = parseInt(ctx.url.searchParams.get('limit') || '100', 10);
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
// Check permissions
// Users can view audit logs for:
// 1. Their own actions (actorId = userId)
// 2. Organizations they manage
// 3. System admins can view all
let actorId: string | undefined;
if (ctx.actor.user?.isSystemAdmin) {
// System admins can see all
actorId = ctx.url.searchParams.get('actorId') || undefined;
} else if (organizationId) {
// Check if user can manage this org
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
organizationId
);
if (!canManage) {
// User can only see their own actions in this org
actorId = ctx.actor.userId;
}
} else {
// Non-admins without org filter can only see their own actions
actorId = ctx.actor.userId;
}
const result = await AuditLog.query({
actorId,
organizationId,
repositoryId,
resourceType,
action: actions,
success,
startDate,
endDate,
limit,
offset,
});
return {
status: 200,
body: {
logs: result.logs.map((log) => ({
id: log.id,
actorId: log.actorId,
actorType: log.actorType,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
resourceName: log.resourceName,
organizationId: log.organizationId,
repositoryId: log.repositoryId,
success: log.success,
errorCode: log.errorCode,
timestamp: log.timestamp,
metadata: log.metadata,
})),
total: result.total,
limit,
offset,
},
};
} catch (error) {
console.error('[AuditApi] Query error:', error);
return { status: 500, body: { error: 'Failed to query audit logs' } };
}
}
}

184
ts/api/handlers/auth.api.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* Auth API handlers
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { AuthService } from '../../services/auth.service.ts';
export class AuthApi {
private authService: AuthService;
constructor(authService: AuthService) {
this.authService = authService;
}
/**
* POST /api/v1/auth/login
*/
public async login(ctx: IApiContext): Promise<IApiResponse> {
try {
const body = await ctx.request.json();
const { email, password } = body;
if (!email || !password) {
return {
status: 400,
body: { error: 'Email and password are required' },
};
}
const result = await this.authService.login(email, password, {
userAgent: ctx.userAgent,
ipAddress: ctx.ip,
});
if (!result.success) {
return {
status: 401,
body: {
error: result.errorMessage,
code: result.errorCode,
},
};
}
return {
status: 200,
body: {
user: {
id: result.user!.id,
email: result.user!.email,
username: result.user!.username,
displayName: result.user!.displayName,
isSystemAdmin: result.user!.isSystemAdmin,
},
accessToken: result.accessToken,
refreshToken: result.refreshToken,
sessionId: result.sessionId,
},
};
} catch (error) {
console.error('[AuthApi] Login error:', error);
return {
status: 500,
body: { error: 'Login failed' },
};
}
}
/**
* POST /api/v1/auth/refresh
*/
public async refresh(ctx: IApiContext): Promise<IApiResponse> {
try {
const body = await ctx.request.json();
const { refreshToken } = body;
if (!refreshToken) {
return {
status: 400,
body: { error: 'Refresh token is required' },
};
}
const result = await this.authService.refresh(refreshToken);
if (!result.success) {
return {
status: 401,
body: {
error: result.errorMessage,
code: result.errorCode,
},
};
}
return {
status: 200,
body: {
accessToken: result.accessToken,
},
};
} catch (error) {
console.error('[AuthApi] Refresh error:', error);
return {
status: 500,
body: { error: 'Token refresh failed' },
};
}
}
/**
* POST /api/v1/auth/logout
*/
public async logout(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return {
status: 401,
body: { error: 'Authentication required' },
};
}
try {
const body = await ctx.request.json().catch(() => ({}));
const { sessionId, all } = body;
if (all) {
const count = await this.authService.logoutAll(ctx.actor.userId, {
ipAddress: ctx.ip,
});
return {
status: 200,
body: { message: `Logged out from ${count} sessions` },
};
}
if (sessionId) {
await this.authService.logout(sessionId, {
userId: ctx.actor.userId,
ipAddress: ctx.ip,
});
}
return {
status: 200,
body: { message: 'Logged out successfully' },
};
} catch (error) {
console.error('[AuthApi] Logout error:', error);
return {
status: 500,
body: { error: 'Logout failed' },
};
}
}
/**
* GET /api/v1/auth/me
*/
public async me(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId || !ctx.actor.user) {
return {
status: 401,
body: { error: 'Authentication required' },
};
}
const user = ctx.actor.user;
return {
status: 200,
body: {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isSystemAdmin: user.isSystemAdmin,
isActive: user.isActive,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
},
};
}
}

11
ts/api/handlers/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* API handler exports
*/
export { AuthApi } from './auth.api.ts';
export { UserApi } from './user.api.ts';
export { OrganizationApi } from './organization.api.ts';
export { RepositoryApi } from './repository.api.ts';
export { PackageApi } from './package.api.ts';
export { TokenApi } from './token.api.ts';
export { AuditApi } from './audit.api.ts';

View File

@@ -0,0 +1,494 @@
/**
* Organization API handlers
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
import { Organization, OrganizationMember, User } from '../../models/index.ts';
import type { TOrganizationRole } from '../../interfaces/auth.interfaces.ts';
export class OrganizationApi {
private permissionService: PermissionService;
constructor(permissionService: PermissionService) {
this.permissionService = permissionService;
}
/**
* GET /api/v1/organizations
*/
public async list(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
try {
// System admins see all orgs, others see only their orgs
let organizations: Organization[];
if (ctx.actor.user?.isSystemAdmin) {
organizations = await Organization.getInstances({});
} else {
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
}
return {
status: 200,
body: {
organizations: organizations.map((org) => ({
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
isPublic: org.isPublic,
memberCount: org.memberCount,
createdAt: org.createdAt,
})),
},
};
} catch (error) {
console.error('[OrganizationApi] List error:', error);
return { status: 500, body: { error: 'Failed to list organizations' } };
}
}
/**
* GET /api/v1/organizations/:id
*/
public async get(ctx: IApiContext): Promise<IApiResponse> {
const { id } = ctx.params;
try {
const org = await Organization.findById(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check access - public orgs are visible to all authenticated users
if (!org.isPublic && ctx.actor?.userId) {
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
}
return {
status: 200,
body: {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
website: org.website,
isPublic: org.isPublic,
memberCount: org.memberCount,
settings: ctx.actor?.user?.isSystemAdmin ? org.settings : undefined,
usedStorageBytes: org.usedStorageBytes,
createdAt: org.createdAt,
},
};
} catch (error) {
console.error('[OrganizationApi] Get error:', error);
return { status: 500, body: { error: 'Failed to get organization' } };
}
}
/**
* POST /api/v1/organizations
*/
public async create(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
try {
const body = await ctx.request.json();
const { name, displayName, description, isPublic } = body;
if (!name) {
return { status: 400, body: { error: 'Organization name is required' } };
}
// Validate name format
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
return {
status: 400,
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
};
}
// Check if name is taken
const existing = await Organization.findByName(name);
if (existing) {
return { status: 409, body: { error: 'Organization name already taken' } };
}
// Create organization
const org = new Organization();
org.id = await Organization.getNewId();
org.name = name;
org.displayName = displayName || name;
org.description = description;
org.isPublic = isPublic ?? false;
org.memberCount = 1;
org.createdAt = new Date();
org.createdById = ctx.actor.userId;
await org.save();
// Add creator as owner
const membership = new OrganizationMember();
membership.id = await OrganizationMember.getNewId();
membership.organizationId = org.id;
membership.userId = ctx.actor.userId;
membership.role = 'owner';
membership.addedById = ctx.actor.userId;
membership.addedAt = new Date();
await membership.save();
// Audit log
await AuditService.withContext({
actorId: ctx.actor.userId,
actorType: 'user',
actorIp: ctx.ip,
}).logOrganizationCreated(org.id, org.name);
return {
status: 201,
body: {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
isPublic: org.isPublic,
createdAt: org.createdAt,
},
};
} catch (error) {
console.error('[OrganizationApi] Create error:', error);
return { status: 500, body: { error: 'Failed to create organization' } };
}
}
/**
* PUT /api/v1/organizations/:id
*/
public async update(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const org = await Organization.findById(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
const body = await ctx.request.json();
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
if (displayName !== undefined) org.displayName = displayName;
if (description !== undefined) org.description = description;
if (avatarUrl !== undefined) org.avatarUrl = avatarUrl;
if (website !== undefined) org.website = website;
if (isPublic !== undefined) org.isPublic = isPublic;
// Only system admins can change settings
if (settings && ctx.actor.user?.isSystemAdmin) {
org.settings = { ...org.settings, ...settings };
}
await org.save();
return {
status: 200,
body: {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
website: org.website,
isPublic: org.isPublic,
},
};
} catch (error) {
console.error('[OrganizationApi] Update error:', error);
return { status: 500, body: { error: 'Failed to update organization' } };
}
}
/**
* DELETE /api/v1/organizations/:id
*/
public async delete(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Only owners and system admins can delete
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Owner access required' } };
}
try {
const org = await Organization.findById(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// TODO: Check for packages, repositories before deletion
// For now, just delete the organization and memberships
await org.delete();
return {
status: 200,
body: { message: 'Organization deleted successfully' },
};
} catch (error) {
console.error('[OrganizationApi] Delete error:', error);
return { status: 500, body: { error: 'Failed to delete organization' } };
}
}
/**
* GET /api/v1/organizations/:id/members
*/
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Check membership
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
try {
const members = await OrganizationMember.getOrgMembers(id);
// Fetch user details
const membersWithUsers = await Promise.all(
members.map(async (m) => {
const user = await User.findById(m.userId);
return {
userId: m.userId,
role: m.role,
addedAt: m.addedAt,
user: user
? {
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
}
: null,
};
})
);
return {
status: 200,
body: { members: membersWithUsers },
};
} catch (error) {
console.error('[OrganizationApi] List members error:', error);
return { status: 500, body: { error: 'Failed to list members' } };
}
}
/**
* POST /api/v1/organizations/:id/members
*/
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const body = await ctx.request.json();
const { userId, role } = body as { userId: string; role: TOrganizationRole };
if (!userId || !role) {
return { status: 400, body: { error: 'userId and role are required' } };
}
if (!['owner', 'admin', 'member'].includes(role)) {
return { status: 400, body: { error: 'Invalid role' } };
}
// Check user exists
const user = await User.findById(userId);
if (!user) {
return { status: 404, body: { error: 'User not found' } };
}
// Check if already a member
const existing = await OrganizationMember.findMembership(id, userId);
if (existing) {
return { status: 409, body: { error: 'User is already a member' } };
}
// Add member
const membership = new OrganizationMember();
membership.id = await OrganizationMember.getNewId();
membership.organizationId = id;
membership.userId = userId;
membership.role = role;
membership.addedById = ctx.actor.userId;
membership.addedAt = new Date();
await membership.save();
// Update member count
const org = await Organization.findById(id);
if (org) {
org.memberCount += 1;
await org.save();
}
return {
status: 201,
body: {
userId: membership.userId,
role: membership.role,
addedAt: membership.addedAt,
},
};
} catch (error) {
console.error('[OrganizationApi] Add member error:', error);
return { status: 500, body: { error: 'Failed to add member' } };
}
}
/**
* PUT /api/v1/organizations/:id/members/:userId
*/
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id, userId } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const body = await ctx.request.json();
const { role } = body as { role: TOrganizationRole };
if (!role || !['owner', 'admin', 'member'].includes(role)) {
return { status: 400, body: { error: 'Valid role is required' } };
}
const membership = await OrganizationMember.findMembership(id, userId);
if (!membership) {
return { status: 404, body: { error: 'Member not found' } };
}
// Cannot change last owner
if (membership.role === 'owner' && role !== 'owner') {
const owners = await OrganizationMember.getOrgMembers(id);
const ownerCount = owners.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) {
return { status: 400, body: { error: 'Cannot remove the last owner' } };
}
}
membership.role = role;
await membership.save();
return {
status: 200,
body: {
userId: membership.userId,
role: membership.role,
},
};
} catch (error) {
console.error('[OrganizationApi] Update member error:', error);
return { status: 500, body: { error: 'Failed to update member' } };
}
}
/**
* DELETE /api/v1/organizations/:id/members/:userId
*/
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id, userId } = ctx.params;
// Users can remove themselves, admins can remove others
if (userId !== ctx.actor.userId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
}
try {
const membership = await OrganizationMember.findMembership(id, userId);
if (!membership) {
return { status: 404, body: { error: 'Member not found' } };
}
// Cannot remove last owner
if (membership.role === 'owner') {
const owners = await OrganizationMember.getOrgMembers(id);
const ownerCount = owners.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) {
return { status: 400, body: { error: 'Cannot remove the last owner' } };
}
}
await membership.delete();
// Update member count
const org = await Organization.findById(id);
if (org) {
org.memberCount = Math.max(0, org.memberCount - 1);
await org.save();
}
return {
status: 200,
body: { message: 'Member removed successfully' },
};
} catch (error) {
console.error('[OrganizationApi] Remove member error:', error);
return { status: 500, body: { error: 'Failed to remove member' } };
}
}
}

View File

@@ -0,0 +1,321 @@
/**
* Package API handlers
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { Package, Repository } from '../../models/index.ts';
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
export class PackageApi {
private permissionService: PermissionService;
constructor(permissionService: PermissionService) {
this.permissionService = permissionService;
}
/**
* GET /api/v1/packages (search)
*/
public async search(ctx: IApiContext): Promise<IApiResponse> {
try {
const query = ctx.url.searchParams.get('q') || '';
const protocol = ctx.url.searchParams.get('protocol') as TRegistryProtocol | undefined;
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
const limit = parseInt(ctx.url.searchParams.get('limit') || '50', 10);
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
// For authenticated users, search includes private packages they have access to
// For anonymous users, only search public packages
const isPrivate = ctx.actor?.userId ? undefined : false;
const packages = await Package.search(query, {
protocol,
organizationId,
isPrivate,
limit,
offset,
});
// Filter out packages user doesn't have access to
const accessiblePackages = [];
for (const pkg of packages) {
if (!pkg.isPrivate) {
accessiblePackages.push(pkg);
continue;
}
if (ctx.actor?.userId) {
const canAccess = await this.permissionService.canAccessPackage(
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
);
if (canAccess) {
accessiblePackages.push(pkg);
}
}
}
return {
status: 200,
body: {
packages: accessiblePackages.map((pkg) => ({
id: pkg.id,
name: pkg.name,
description: pkg.description,
protocol: pkg.protocol,
organizationId: pkg.organizationId,
repositoryId: pkg.repositoryId,
latestVersion: pkg.distTags['latest'],
isPrivate: pkg.isPrivate,
downloadCount: pkg.downloadCount,
updatedAt: pkg.updatedAt,
})),
total: accessiblePackages.length,
limit,
offset,
},
};
} catch (error) {
console.error('[PackageApi] Search error:', error);
return { status: 500, body: { error: 'Failed to search packages' } };
}
}
/**
* GET /api/v1/packages/:id
*/
public async get(ctx: IApiContext): Promise<IApiResponse> {
const { id } = ctx.params;
try {
const pkg = await Package.findById(decodeURIComponent(id));
if (!pkg) {
return { status: 404, body: { error: 'Package not found' } };
}
// Check access
if (pkg.isPrivate) {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const canAccess = await this.permissionService.canAccessPackage(
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
);
if (!canAccess) {
return { status: 403, body: { error: 'Access denied' } };
}
}
return {
status: 200,
body: {
id: pkg.id,
name: pkg.name,
description: pkg.description,
protocol: pkg.protocol,
organizationId: pkg.organizationId,
repositoryId: pkg.repositoryId,
distTags: pkg.distTags,
versions: Object.keys(pkg.versions),
isPrivate: pkg.isPrivate,
downloadCount: pkg.downloadCount,
starCount: pkg.starCount,
storageBytes: pkg.storageBytes,
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
},
};
} catch (error) {
console.error('[PackageApi] Get error:', error);
return { status: 500, body: { error: 'Failed to get package' } };
}
}
/**
* GET /api/v1/packages/:id/versions
*/
public async listVersions(ctx: IApiContext): Promise<IApiResponse> {
const { id } = ctx.params;
try {
const pkg = await Package.findById(decodeURIComponent(id));
if (!pkg) {
return { status: 404, body: { error: 'Package not found' } };
}
// Check access
if (pkg.isPrivate) {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const canAccess = await this.permissionService.canAccessPackage(
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
);
if (!canAccess) {
return { status: 403, body: { error: 'Access denied' } };
}
}
const versions = Object.entries(pkg.versions).map(([version, data]) => ({
version,
publishedAt: data.publishedAt,
size: data.size,
downloads: data.downloads,
checksum: data.checksum,
}));
return {
status: 200,
body: {
packageId: pkg.id,
packageName: pkg.name,
distTags: pkg.distTags,
versions,
},
};
} catch (error) {
console.error('[PackageApi] List versions error:', error);
return { status: 500, body: { error: 'Failed to list versions' } };
}
}
/**
* DELETE /api/v1/packages/:id
*/
public async delete(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
try {
const pkg = await Package.findById(decodeURIComponent(id));
if (!pkg) {
return { status: 404, body: { error: 'Package not found' } };
}
// Check delete permission
const canDelete = await this.permissionService.canAccessPackage(
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'delete'
);
if (!canDelete) {
return { status: 403, body: { error: 'Delete permission required' } };
}
// Delete the package
await pkg.delete();
// Update repository package count
const repo = await Repository.findById(pkg.repositoryId);
if (repo) {
repo.packageCount = Math.max(0, repo.packageCount - 1);
repo.storageBytes -= pkg.storageBytes;
await repo.save();
}
return {
status: 200,
body: { message: 'Package deleted successfully' },
};
} catch (error) {
console.error('[PackageApi] Delete error:', error);
return { status: 500, body: { error: 'Failed to delete package' } };
}
}
/**
* DELETE /api/v1/packages/:id/versions/:version
*/
public async deleteVersion(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id, version } = ctx.params;
try {
const pkg = await Package.findById(decodeURIComponent(id));
if (!pkg) {
return { status: 404, body: { error: 'Package not found' } };
}
const versionData = pkg.versions[version];
if (!versionData) {
return { status: 404, body: { error: 'Version not found' } };
}
// Check delete permission
const canDelete = await this.permissionService.canAccessPackage(
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'delete'
);
if (!canDelete) {
return { status: 403, body: { error: 'Delete permission required' } };
}
// Check if this is the only version
if (Object.keys(pkg.versions).length === 1) {
return {
status: 400,
body: { error: 'Cannot delete the only version. Delete the entire package instead.' },
};
}
// Remove version
const sizeReduction = versionData.size;
delete pkg.versions[version];
pkg.storageBytes -= sizeReduction;
// Update dist tags
for (const [tag, tagVersion] of Object.entries(pkg.distTags)) {
if (tagVersion === version) {
delete pkg.distTags[tag];
}
}
// Set new latest if needed
if (!pkg.distTags['latest'] && Object.keys(pkg.versions).length > 0) {
const versions = Object.keys(pkg.versions).sort();
pkg.distTags['latest'] = versions[versions.length - 1];
}
await pkg.save();
// Update repository storage
const repo = await Repository.findById(pkg.repositoryId);
if (repo) {
repo.storageBytes -= sizeReduction;
await repo.save();
}
return {
status: 200,
body: { message: 'Version deleted successfully' },
};
} catch (error) {
console.error('[PackageApi] Delete version error:', error);
return { status: 500, body: { error: 'Failed to delete version' } };
}
}
}

View File

@@ -0,0 +1,293 @@
/**
* Repository API handlers
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
import { Repository, Organization } from '../../models/index.ts';
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
export class RepositoryApi {
private permissionService: PermissionService;
constructor(permissionService: PermissionService) {
this.permissionService = permissionService;
}
/**
* GET /api/v1/organizations/:orgId/repositories
*/
public async list(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { orgId } = ctx.params;
try {
// Get accessible repositories
const repositories = await this.permissionService.getAccessibleRepositories(
ctx.actor.userId,
orgId
);
return {
status: 200,
body: {
repositories: repositories.map((repo) => ({
id: repo.id,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
isPublic: repo.isPublic,
packageCount: repo.packageCount,
createdAt: repo.createdAt,
})),
},
};
} catch (error) {
console.error('[RepositoryApi] List error:', error);
return { status: 500, body: { error: 'Failed to list repositories' } };
}
}
/**
* GET /api/v1/repositories/:id
*/
public async get(ctx: IApiContext): Promise<IApiResponse> {
const { id } = ctx.params;
try {
const repo = await Repository.findById(id);
if (!repo) {
return { status: 404, body: { error: 'Repository not found' } };
}
// Check access
if (!repo.isPublic && ctx.actor?.userId) {
const permissions = await this.permissionService.resolvePermissions({
userId: ctx.actor.userId,
organizationId: repo.organizationId,
repositoryId: repo.id,
});
if (!permissions.canRead) {
return { status: 403, body: { error: 'Access denied' } };
}
}
return {
status: 200,
body: {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
isPublic: repo.isPublic,
settings: repo.settings,
packageCount: repo.packageCount,
storageBytes: repo.storageBytes,
createdAt: repo.createdAt,
},
};
} catch (error) {
console.error('[RepositoryApi] Get error:', error);
return { status: 500, body: { error: 'Failed to get repository' } };
}
}
/**
* POST /api/v1/organizations/:orgId/repositories
*/
public async create(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { orgId } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, orgId);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const body = await ctx.request.json();
const { name, displayName, description, protocols, isPublic, settings } = body;
if (!name) {
return { status: 400, body: { error: 'Repository name is required' } };
}
// Validate name format
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
return {
status: 400,
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
};
}
// Check org exists
const org = await Organization.findById(orgId);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check if name is taken in this org
const existing = await Repository.findByName(orgId, name);
if (existing) {
return { status: 409, body: { error: 'Repository name already taken in this organization' } };
}
// Create repository
const repo = new Repository();
repo.id = await Repository.getNewId();
repo.organizationId = orgId;
repo.name = name;
repo.displayName = displayName || name;
repo.description = description;
repo.protocols = protocols || ['npm'];
repo.isPublic = isPublic ?? false;
repo.settings = settings || {
allowOverwrite: false,
immutableTags: false,
retentionDays: 0,
};
repo.createdAt = new Date();
repo.createdById = ctx.actor.userId;
await repo.save();
// Audit log
await AuditService.withContext({
actorId: ctx.actor.userId,
actorType: 'user',
actorIp: ctx.ip,
organizationId: orgId,
}).logRepositoryCreated(repo.id, repo.name, orgId);
return {
status: 201,
body: {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
isPublic: repo.isPublic,
createdAt: repo.createdAt,
},
};
} catch (error) {
console.error('[RepositoryApi] Create error:', error);
return { status: 500, body: { error: 'Failed to create repository' } };
}
}
/**
* PUT /api/v1/repositories/:id
*/
public async update(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
try {
const repo = await Repository.findById(id);
if (!repo) {
return { status: 404, body: { error: 'Repository not found' } };
}
// Check admin permission
const canManage = await this.permissionService.canManageRepository(
ctx.actor.userId,
repo.organizationId,
id
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
const body = await ctx.request.json();
const { displayName, description, protocols, isPublic, settings } = body;
if (displayName !== undefined) repo.displayName = displayName;
if (description !== undefined) repo.description = description;
if (protocols !== undefined) repo.protocols = protocols;
if (isPublic !== undefined) repo.isPublic = isPublic;
if (settings !== undefined) repo.settings = { ...repo.settings, ...settings };
await repo.save();
return {
status: 200,
body: {
id: repo.id,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
isPublic: repo.isPublic,
settings: repo.settings,
},
};
} catch (error) {
console.error('[RepositoryApi] Update error:', error);
return { status: 500, body: { error: 'Failed to update repository' } };
}
}
/**
* DELETE /api/v1/repositories/:id
*/
public async delete(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
try {
const repo = await Repository.findById(id);
if (!repo) {
return { status: 404, body: { error: 'Repository not found' } };
}
// Check admin permission
const canManage = await this.permissionService.canManageRepository(
ctx.actor.userId,
repo.organizationId,
id
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
// Check for packages
if (repo.packageCount > 0) {
return {
status: 400,
body: { error: 'Cannot delete repository with packages. Remove all packages first.' },
};
}
await repo.delete();
return {
status: 200,
body: { message: 'Repository deleted successfully' },
};
} catch (error) {
console.error('[RepositoryApi] Delete error:', error);
return { status: 500, body: { error: 'Failed to delete repository' } };
}
}
}

View File

@@ -0,0 +1,157 @@
/**
* Token API handlers
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { TokenService } from '../../services/token.service.ts';
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
export class TokenApi {
private tokenService: TokenService;
constructor(tokenService: TokenService) {
this.tokenService = tokenService;
}
/**
* GET /api/v1/tokens
*/
public async list(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
try {
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
return {
status: 200,
body: {
tokens: tokens.map((t) => ({
id: t.id,
name: t.name,
tokenPrefix: t.tokenPrefix,
protocols: t.protocols,
scopes: t.scopes,
expiresAt: t.expiresAt,
lastUsedAt: t.lastUsedAt,
usageCount: t.usageCount,
createdAt: t.createdAt,
})),
},
};
} catch (error) {
console.error('[TokenApi] List error:', error);
return { status: 500, body: { error: 'Failed to list tokens' } };
}
}
/**
* POST /api/v1/tokens
*/
public async create(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
try {
const body = await ctx.request.json();
const { name, protocols, scopes, expiresInDays } = body as {
name: string;
protocols: TRegistryProtocol[];
scopes: ITokenScope[];
expiresInDays?: number;
};
if (!name) {
return { status: 400, body: { error: 'Token name is required' } };
}
if (!protocols || protocols.length === 0) {
return { status: 400, body: { error: 'At least one protocol is required' } };
}
if (!scopes || scopes.length === 0) {
return { status: 400, body: { error: 'At least one scope is required' } };
}
// Validate protocols
const validProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems', '*'];
for (const protocol of protocols) {
if (!validProtocols.includes(protocol)) {
return { status: 400, body: { error: `Invalid protocol: ${protocol}` } };
}
}
// Validate scopes
for (const scope of scopes) {
if (!scope.protocol || !scope.actions || scope.actions.length === 0) {
return { status: 400, body: { error: 'Invalid scope configuration' } };
}
}
const result = await this.tokenService.createToken({
userId: ctx.actor.userId,
name,
protocols,
scopes,
expiresInDays,
createdIp: ctx.ip,
});
return {
status: 201,
body: {
id: result.token.id,
name: result.token.name,
token: result.rawToken, // Only returned once!
tokenPrefix: result.token.tokenPrefix,
protocols: result.token.protocols,
scopes: result.token.scopes,
expiresAt: result.token.expiresAt,
createdAt: result.token.createdAt,
warning: 'Store this token securely. It will not be shown again.',
},
};
} catch (error) {
console.error('[TokenApi] Create error:', error);
return { status: 500, body: { error: 'Failed to create token' } };
}
}
/**
* DELETE /api/v1/tokens/:id
*/
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
try {
// Get the token to verify ownership
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
const token = tokens.find((t) => t.id === id);
if (!token) {
// Either doesn't exist or doesn't belong to user
return { status: 404, body: { error: 'Token not found' } };
}
const success = await this.tokenService.revokeToken(id, 'user_revoked');
if (!success) {
return { status: 500, body: { error: 'Failed to revoke token' } };
}
return {
status: 200,
body: { message: 'Token revoked successfully' },
};
} catch (error) {
console.error('[TokenApi] Revoke error:', error);
return { status: 500, body: { error: 'Failed to revoke token' } };
}
}
}

260
ts/api/handlers/user.api.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* User API handlers
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuthService } from '../../services/auth.service.ts';
import { User } from '../../models/user.ts';
export class UserApi {
private permissionService: PermissionService;
constructor(permissionService: PermissionService) {
this.permissionService = permissionService;
}
/**
* GET /api/v1/users
*/
public async list(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
// Only system admins can list all users
if (!ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'System admin access required' } };
}
try {
const users = await User.getInstances({});
return {
status: 200,
body: {
users: users.map((u) => ({
id: u.id,
email: u.email,
username: u.username,
displayName: u.displayName,
isSystemAdmin: u.isSystemAdmin,
isActive: u.isActive,
createdAt: u.createdAt,
lastLoginAt: u.lastLoginAt,
})),
},
};
} catch (error) {
console.error('[UserApi] List error:', error);
return { status: 500, body: { error: 'Failed to list users' } };
}
}
/**
* GET /api/v1/users/:id
*/
public async get(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Users can view their own profile, admins can view any
if (id !== ctx.actor.userId && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
try {
const user = await User.findById(id);
if (!user) {
return { status: 404, body: { error: 'User not found' } };
}
return {
status: 200,
body: {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isSystemAdmin: user.isSystemAdmin,
isActive: user.isActive,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
},
};
} catch (error) {
console.error('[UserApi] Get error:', error);
return { status: 500, body: { error: 'Failed to get user' } };
}
}
/**
* POST /api/v1/users
*/
public async create(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
// Only system admins can create users
if (!ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'System admin access required' } };
}
try {
const body = await ctx.request.json();
const { email, username, password, displayName, isSystemAdmin } = body;
if (!email || !username || !password) {
return {
status: 400,
body: { error: 'Email, username, and password are required' },
};
}
// Check if email already exists
const existing = await User.findByEmail(email);
if (existing) {
return { status: 409, body: { error: 'Email already in use' } };
}
// Check if username already exists
const existingUsername = await User.findByUsername(username);
if (existingUsername) {
return { status: 409, body: { error: 'Username already in use' } };
}
// Hash password
const passwordHash = await AuthService.hashPassword(password);
// Create user
const user = new User();
user.id = await User.getNewId();
user.email = email;
user.username = username;
user.passwordHash = passwordHash;
user.displayName = displayName || username;
user.isSystemAdmin = isSystemAdmin || false;
user.isActive = true;
user.createdAt = new Date();
await user.save();
return {
status: 201,
body: {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
isSystemAdmin: user.isSystemAdmin,
createdAt: user.createdAt,
},
};
} catch (error) {
console.error('[UserApi] Create error:', error);
return { status: 500, body: { error: 'Failed to create user' } };
}
}
/**
* PUT /api/v1/users/:id
*/
public async update(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Users can update their own profile, admins can update any
if (id !== ctx.actor.userId && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
try {
const user = await User.findById(id);
if (!user) {
return { status: 404, body: { error: 'User not found' } };
}
const body = await ctx.request.json();
const { displayName, avatarUrl, password, isActive, isSystemAdmin } = body;
if (displayName !== undefined) user.displayName = displayName;
if (avatarUrl !== undefined) user.avatarUrl = avatarUrl;
// Only admins can change these
if (ctx.actor.user?.isSystemAdmin) {
if (isActive !== undefined) user.isActive = isActive;
if (isSystemAdmin !== undefined) user.isSystemAdmin = isSystemAdmin;
}
// Password change
if (password) {
user.passwordHash = await AuthService.hashPassword(password);
}
await user.save();
return {
status: 200,
body: {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isSystemAdmin: user.isSystemAdmin,
isActive: user.isActive,
},
};
} catch (error) {
console.error('[UserApi] Update error:', error);
return { status: 500, body: { error: 'Failed to update user' } };
}
}
/**
* DELETE /api/v1/users/:id
*/
public async delete(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
// Only system admins can delete users
if (!ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'System admin access required' } };
}
const { id } = ctx.params;
// Cannot delete yourself
if (id === ctx.actor.userId) {
return { status: 400, body: { error: 'Cannot delete your own account' } };
}
try {
const user = await User.findById(id);
if (!user) {
return { status: 404, body: { error: 'User not found' } };
}
// Soft delete - deactivate instead of removing
user.isActive = false;
await user.save();
return {
status: 200,
body: { message: 'User deactivated successfully' },
};
} catch (error) {
console.error('[UserApi] Delete error:', error);
return { status: 500, body: { error: 'Failed to delete user' } };
}
}
}

6
ts/api/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* API exports
*/
export { ApiRouter, type IApiContext, type IApiResponse } from './router.ts';
export * from './handlers/index.ts';

277
ts/api/router.ts Normal file
View File

@@ -0,0 +1,277 @@
/**
* API Router - Routes REST API requests to appropriate handlers
*/
import type { IStackGalleryActor } from '../providers/auth.provider.ts';
import { AuthService } from '../services/auth.service.ts';
import { TokenService } from '../services/token.service.ts';
import { PermissionService } from '../services/permission.service.ts';
import { AuditService } from '../services/audit.service.ts';
// Import API handlers
import { AuthApi } from './handlers/auth.api.ts';
import { UserApi } from './handlers/user.api.ts';
import { OrganizationApi } from './handlers/organization.api.ts';
import { RepositoryApi } from './handlers/repository.api.ts';
import { PackageApi } from './handlers/package.api.ts';
import { TokenApi } from './handlers/token.api.ts';
import { AuditApi } from './handlers/audit.api.ts';
export interface IApiContext {
request: Request;
url: URL;
path: string;
method: string;
params: Record<string, string>;
actor?: IStackGalleryActor;
ip?: string;
userAgent?: string;
}
export interface IApiResponse {
status: number;
body?: unknown;
headers?: Record<string, string>;
}
type RouteHandler = (ctx: IApiContext) => Promise<IApiResponse>;
interface IRoute {
method: string;
pattern: RegExp;
paramNames: string[];
handler: RouteHandler;
}
export class ApiRouter {
private routes: IRoute[] = [];
private authService: AuthService;
private tokenService: TokenService;
private permissionService: PermissionService;
// API handlers
private authApi: AuthApi;
private userApi: UserApi;
private organizationApi: OrganizationApi;
private repositoryApi: RepositoryApi;
private packageApi: PackageApi;
private tokenApi: TokenApi;
private auditApi: AuditApi;
constructor() {
this.authService = new AuthService();
this.tokenService = new TokenService();
this.permissionService = new PermissionService();
// Initialize API handlers
this.authApi = new AuthApi(this.authService);
this.userApi = new UserApi(this.permissionService);
this.organizationApi = new OrganizationApi(this.permissionService);
this.repositoryApi = new RepositoryApi(this.permissionService);
this.packageApi = new PackageApi(this.permissionService);
this.tokenApi = new TokenApi(this.tokenService);
this.auditApi = new AuditApi(this.permissionService);
this.registerRoutes();
}
/**
* Register all API routes
*/
private registerRoutes(): void {
// Auth routes
this.addRoute('POST', '/api/v1/auth/login', (ctx) => this.authApi.login(ctx));
this.addRoute('POST', '/api/v1/auth/refresh', (ctx) => this.authApi.refresh(ctx));
this.addRoute('POST', '/api/v1/auth/logout', (ctx) => this.authApi.logout(ctx));
this.addRoute('GET', '/api/v1/auth/me', (ctx) => this.authApi.me(ctx));
// User routes
this.addRoute('GET', '/api/v1/users', (ctx) => this.userApi.list(ctx));
this.addRoute('GET', '/api/v1/users/:id', (ctx) => this.userApi.get(ctx));
this.addRoute('POST', '/api/v1/users', (ctx) => this.userApi.create(ctx));
this.addRoute('PUT', '/api/v1/users/:id', (ctx) => this.userApi.update(ctx));
this.addRoute('DELETE', '/api/v1/users/:id', (ctx) => this.userApi.delete(ctx));
// Organization routes
this.addRoute('GET', '/api/v1/organizations', (ctx) => this.organizationApi.list(ctx));
this.addRoute('GET', '/api/v1/organizations/:id', (ctx) => this.organizationApi.get(ctx));
this.addRoute('POST', '/api/v1/organizations', (ctx) => this.organizationApi.create(ctx));
this.addRoute('PUT', '/api/v1/organizations/:id', (ctx) => this.organizationApi.update(ctx));
this.addRoute('DELETE', '/api/v1/organizations/:id', (ctx) => this.organizationApi.delete(ctx));
this.addRoute('GET', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.listMembers(ctx));
this.addRoute('POST', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.addMember(ctx));
this.addRoute('PUT', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.updateMember(ctx));
this.addRoute('DELETE', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.removeMember(ctx));
// Repository routes
this.addRoute('GET', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.list(ctx));
this.addRoute('GET', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.get(ctx));
this.addRoute('POST', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.create(ctx));
this.addRoute('PUT', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.update(ctx));
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
// Package routes
this.addRoute('GET', '/api/v1/packages', (ctx) => this.packageApi.search(ctx));
this.addRoute('GET', '/api/v1/packages/:id', (ctx) => this.packageApi.get(ctx));
this.addRoute('GET', '/api/v1/packages/:id/versions', (ctx) => this.packageApi.listVersions(ctx));
this.addRoute('DELETE', '/api/v1/packages/:id', (ctx) => this.packageApi.delete(ctx));
this.addRoute('DELETE', '/api/v1/packages/:id/versions/:version', (ctx) => this.packageApi.deleteVersion(ctx));
// Token routes
this.addRoute('GET', '/api/v1/tokens', (ctx) => this.tokenApi.list(ctx));
this.addRoute('POST', '/api/v1/tokens', (ctx) => this.tokenApi.create(ctx));
this.addRoute('DELETE', '/api/v1/tokens/:id', (ctx) => this.tokenApi.revoke(ctx));
// Audit routes
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
}
/**
* Add a route
*/
private addRoute(method: string, path: string, handler: RouteHandler): void {
const paramNames: string[] = [];
const patternStr = path.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
});
const pattern = new RegExp(`^${patternStr}$`);
this.routes.push({ method, pattern, paramNames, handler });
}
/**
* Handle an API request
*/
public async handle(request: Request): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
// Find matching route
for (const route of this.routes) {
if (route.method !== method) continue;
const match = path.match(route.pattern);
if (!match) continue;
// Extract params
const params: Record<string, string> = {};
route.paramNames.forEach((name, i) => {
params[name] = match[i + 1];
});
// Build context
const ctx: IApiContext = {
request,
url,
path,
method,
params,
ip: this.getClientIp(request),
userAgent: request.headers.get('user-agent') || undefined,
};
// Authenticate request (except for login)
if (!path.includes('/auth/login')) {
ctx.actor = await this.authenticateRequest(request);
}
try {
const result = await route.handler(ctx);
return this.buildResponse(result);
} catch (error) {
console.error('[ApiRouter] Handler error:', error);
return this.buildResponse({
status: 500,
body: { error: 'Internal server error' },
});
}
}
return this.buildResponse({
status: 404,
body: { error: 'Not found' },
});
}
/**
* Authenticate request from headers
*/
private async authenticateRequest(request: Request): Promise<IStackGalleryActor | undefined> {
const authHeader = request.headers.get('authorization');
// Try Bearer token (JWT)
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
// Check if it's a JWT (for UI) or API token
if (token.startsWith('srg_')) {
// API token
const result = await this.tokenService.validateToken(token, this.getClientIp(request));
if (result.valid && result.token && result.user) {
return {
type: 'api_token',
userId: result.user.id,
user: result.user,
tokenId: result.token.id,
ip: this.getClientIp(request),
userAgent: request.headers.get('user-agent') || undefined,
protocols: result.token.protocols,
permissions: {
canRead: true,
canWrite: true,
canDelete: true,
},
};
}
} else {
// JWT token
const result = await this.authService.validateAccessToken(token);
if (result) {
return {
type: 'user',
userId: result.user.id,
user: result.user,
ip: this.getClientIp(request),
userAgent: request.headers.get('user-agent') || undefined,
protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
permissions: {
canRead: true,
canWrite: true,
canDelete: true,
},
};
}
}
}
return undefined;
}
/**
* Get client IP from request
*/
private getClientIp(request: Request): string {
return (
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
request.headers.get('x-real-ip') ||
'unknown'
);
}
/**
* Build HTTP response from API response
*/
private buildResponse(result: IApiResponse): Response {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...result.headers,
};
return new Response(result.body ? JSON.stringify(result.body) : null, {
status: result.status,
headers,
});
}
}

108
ts/cli.ts Normal file
View 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
View 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';

View 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[];
}

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

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

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

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

View 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
View File

@@ -0,0 +1,6 @@
/**
* Provider exports
*/
export { StackGalleryAuthProvider, type IStackGalleryActor } from './auth.provider.ts';
export { StackGalleryStorageHooks, type IStorageConfig } from './storage.provider.ts';

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

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

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

View 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}...`;
}
}