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

46
deno.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "@stack.gallery/registry",
"version": "1.0.0",
"exports": "./mod.ts",
"tasks": {
"start": "deno run --allow-all mod.ts server",
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
"test": "deno test --allow-all",
"build": "cd ui && pnpm run build"
},
"imports": {
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.5.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^5.0.0",
"@push.rocks/smartbucket": "npm:@push.rocks/smartbucket@^4.3.0",
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.0.0",
"@push.rocks/smartenv": "npm:@push.rocks/smartenv@^5.0.0",
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^5.0.0",
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.0.0",
"@push.rocks/smartstring": "npm:@push.rocks/smartstring@^4.0.0",
"@push.rocks/smartcrypto": "npm:@push.rocks/smartcrypto@^2.0.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.0.0",
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.0",
"@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.0",
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.0",
"@push.rocks/smartcli": "npm:@push.rocks/smartcli@^4.0.0",
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.0.0",
"@std/path": "jsr:@std/path@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.0",
"@std/http": "jsr:@std/http@^1.0.0"
},
"compilerOptions": {
"strict": true,
"lib": ["deno.window", "dom"],
"jsx": "react-jsx",
"jsxImportSource": "npm:react"
},
"lint": {
"rules": {
"exclude": ["no-explicit-any"]
}
},
"fmt": {
"singleQuote": true,
"lineWidth": 100
}
}

11
mod.ts Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env -S deno run --allow-all
/**
* Stack.Gallery Registry
* Enterprise-grade multi-protocol package registry
*/
import { runCli } from './ts/cli.ts';
// Run CLI
await runCli();

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@stack.gallery/registry",
"version": "1.0.0",
"private": true,
"description": "Enterprise-grade multi-protocol package registry",
"type": "module",
"scripts": {
"start": "deno run --allow-all mod.ts server",
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
"build": "cd ui && pnpm run build",
"test": "deno test --allow-all"
},
"keywords": [
"registry",
"npm",
"docker",
"oci",
"maven",
"cargo",
"pypi",
"rubygems",
"composer"
],
"author": "Stack.Gallery",
"license": "MIT"
}

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

94
ui/angular.json Normal file
View File

@@ -0,0 +1,94 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"registry-ui": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/registry-ui",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "registry-ui:build:production"
},
"development": {
"buildTarget": "registry-ui:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
}
}
}
}
}
}

34
ui/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@stack.gallery/registry-ui",
"version": "1.0.0",
"private": true,
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.0.0",
"@angular/core": "^19.0.0",
"@angular/forms": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.6.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.0",
"@angular/cli": "^19.0.0",
"@angular/compiler-cli": "^19.0.0",
"@types/node": "^20.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "~5.6.0"
}
}

6
ui/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

7
ui/proxy.conf.json Normal file
View File

@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}

0
ui/public/favicon.ico Normal file
View File

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
})
export class AppComponent {}

13
ui/src/app/app.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};

95
ui/src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,95 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const routes: Routes = [
{
path: 'login',
loadComponent: () =>
import('./features/login/login.component').then((m) => m.LoginComponent),
},
{
path: '',
loadComponent: () =>
import('./shared/components/layout/layout.component').then(
(m) => m.LayoutComponent
),
canActivate: [authGuard],
children: [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full',
},
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'organizations',
children: [
{
path: '',
loadComponent: () =>
import('./features/organizations/organizations.component').then(
(m) => m.OrganizationsComponent
),
},
{
path: ':orgId',
loadComponent: () =>
import('./features/organizations/organization-detail.component').then(
(m) => m.OrganizationDetailComponent
),
},
{
path: ':orgId/repositories/:repoId',
loadComponent: () =>
import('./features/repositories/repository-detail.component').then(
(m) => m.RepositoryDetailComponent
),
},
],
},
{
path: 'packages',
children: [
{
path: '',
loadComponent: () =>
import('./features/packages/packages.component').then(
(m) => m.PackagesComponent
),
},
{
path: ':packageId',
loadComponent: () =>
import('./features/packages/package-detail.component').then(
(m) => m.PackageDetailComponent
),
},
],
},
{
path: 'tokens',
loadComponent: () =>
import('./features/tokens/tokens.component').then(
(m) => m.TokensComponent
),
},
{
path: 'settings',
loadComponent: () =>
import('./features/settings/settings.component').then(
(m) => m.SettingsComponent
),
},
],
},
{
path: '**',
redirectTo: 'dashboard',
},
];

View File

@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { Router, type CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = async () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
// Try to refresh the token
const refreshed = await authService.refreshAccessToken();
if (refreshed) {
return true;
}
router.navigate(['/login']);
return false;
};

View File

@@ -0,0 +1,59 @@
import { inject } from '@angular/core';
import {
HttpInterceptorFn,
HttpRequest,
HttpHandlerFn,
HttpErrorResponse,
} from '@angular/common/http';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { Router } from '@angular/router';
export const authInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
) => {
const authService = inject(AuthService);
const router = inject(Router);
// Skip auth header for login/refresh endpoints
if (req.url.includes('/auth/login') || req.url.includes('/auth/refresh')) {
return next(req);
}
const token = authService.accessToken;
if (token) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
}
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Try to refresh the token
return new Promise((resolve) => {
authService.refreshAccessToken().then((success) => {
if (success) {
// Retry the request with new token
const newToken = authService.accessToken;
const retryReq = req.clone({
setHeaders: {
Authorization: `Bearer ${newToken}`,
},
});
resolve(next(retryReq));
} else {
// Redirect to login
router.navigate(['/login']);
resolve(throwError(() => error));
}
});
}).then((result) => result as ReturnType<HttpHandlerFn>);
}
return throwError(() => error);
})
);
};

View File

@@ -0,0 +1,226 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
// Types
export interface IOrganization {
id: string;
name: string;
displayName: string;
description?: string;
avatarUrl?: string;
isPublic: boolean;
memberCount: number;
createdAt: string;
}
export interface IRepository {
id: string;
organizationId: string;
name: string;
displayName: string;
description?: string;
protocols: string[];
isPublic: boolean;
packageCount: number;
createdAt: string;
}
export interface IPackage {
id: string;
name: string;
description?: string;
protocol: string;
organizationId: string;
repositoryId: string;
latestVersion?: string;
isPrivate: boolean;
downloadCount: number;
updatedAt: string;
}
export interface IToken {
id: string;
name: string;
tokenPrefix: string;
protocols: string[];
expiresAt?: string;
lastUsedAt?: string;
usageCount: number;
createdAt: string;
}
export interface IAuditLog {
id: string;
actorId?: string;
actorType: string;
action: string;
resourceType: string;
resourceId?: string;
resourceName?: string;
success: boolean;
timestamp: string;
}
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly baseUrl = '/api/v1';
constructor(private http: HttpClient) {}
// Organizations
getOrganizations(): Observable<{ organizations: IOrganization[] }> {
return this.http.get<{ organizations: IOrganization[] }>(
`${this.baseUrl}/organizations`
);
}
getOrganization(id: string): Observable<IOrganization> {
return this.http.get<IOrganization>(`${this.baseUrl}/organizations/${id}`);
}
createOrganization(data: {
name: string;
displayName?: string;
description?: string;
isPublic?: boolean;
}): Observable<IOrganization> {
return this.http.post<IOrganization>(`${this.baseUrl}/organizations`, data);
}
updateOrganization(
id: string,
data: Partial<IOrganization>
): Observable<IOrganization> {
return this.http.put<IOrganization>(
`${this.baseUrl}/organizations/${id}`,
data
);
}
deleteOrganization(id: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(
`${this.baseUrl}/organizations/${id}`
);
}
// Repositories
getRepositories(orgId: string): Observable<{ repositories: IRepository[] }> {
return this.http.get<{ repositories: IRepository[] }>(
`${this.baseUrl}/organizations/${orgId}/repositories`
);
}
getRepository(id: string): Observable<IRepository> {
return this.http.get<IRepository>(`${this.baseUrl}/repositories/${id}`);
}
createRepository(
orgId: string,
data: {
name: string;
displayName?: string;
description?: string;
protocols?: string[];
isPublic?: boolean;
}
): Observable<IRepository> {
return this.http.post<IRepository>(
`${this.baseUrl}/organizations/${orgId}/repositories`,
data
);
}
updateRepository(
id: string,
data: Partial<IRepository>
): Observable<IRepository> {
return this.http.put<IRepository>(`${this.baseUrl}/repositories/${id}`, data);
}
deleteRepository(id: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(
`${this.baseUrl}/repositories/${id}`
);
}
// Packages
searchPackages(params?: {
q?: string;
protocol?: string;
organizationId?: string;
limit?: number;
offset?: number;
}): Observable<{ packages: IPackage[]; total: number }> {
let httpParams = new HttpParams();
if (params?.q) httpParams = httpParams.set('q', params.q);
if (params?.protocol) httpParams = httpParams.set('protocol', params.protocol);
if (params?.organizationId)
httpParams = httpParams.set('organizationId', params.organizationId);
if (params?.limit) httpParams = httpParams.set('limit', params.limit.toString());
if (params?.offset) httpParams = httpParams.set('offset', params.offset.toString());
return this.http.get<{ packages: IPackage[]; total: number }>(
`${this.baseUrl}/packages`,
{ params: httpParams }
);
}
getPackage(id: string): Observable<IPackage> {
return this.http.get<IPackage>(
`${this.baseUrl}/packages/${encodeURIComponent(id)}`
);
}
deletePackage(id: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(
`${this.baseUrl}/packages/${encodeURIComponent(id)}`
);
}
// Tokens
getTokens(): Observable<{ tokens: IToken[] }> {
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`);
}
createToken(data: {
name: string;
protocols: string[];
scopes: { protocol: string; actions: string[] }[];
expiresInDays?: number;
}): Observable<IToken & { token: string }> {
return this.http.post<IToken & { token: string }>(
`${this.baseUrl}/tokens`,
data
);
}
revokeToken(id: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.baseUrl}/tokens/${id}`);
}
// Audit
getAuditLogs(params?: {
organizationId?: string;
resourceType?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}): Observable<{ logs: IAuditLog[]; total: number }> {
let httpParams = new HttpParams();
if (params?.organizationId)
httpParams = httpParams.set('organizationId', params.organizationId);
if (params?.resourceType)
httpParams = httpParams.set('resourceType', params.resourceType);
if (params?.startDate) httpParams = httpParams.set('startDate', params.startDate);
if (params?.endDate) httpParams = httpParams.set('endDate', params.endDate);
if (params?.limit) httpParams = httpParams.set('limit', params.limit.toString());
if (params?.offset) httpParams = httpParams.set('offset', params.offset.toString());
return this.http.get<{ logs: IAuditLog[]; total: number }>(
`${this.baseUrl}/audit`,
{ params: httpParams }
);
}
}

View File

@@ -0,0 +1,148 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
export interface IUser {
id: string;
email: string;
username: string;
displayName: string;
avatarUrl?: string;
isSystemAdmin: boolean;
}
export interface ILoginResponse {
user: IUser;
accessToken: string;
refreshToken: string;
sessionId: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly _user = signal<IUser | null>(null);
private readonly _accessToken = signal<string | null>(null);
private readonly _refreshToken = signal<string | null>(null);
private readonly _sessionId = signal<string | null>(null);
readonly user = this._user.asReadonly();
readonly isAuthenticated = computed(() => !!this._accessToken());
readonly isAdmin = computed(() => this._user()?.isSystemAdmin ?? false);
constructor(
private http: HttpClient,
private router: Router
) {
this.loadFromStorage();
}
get accessToken(): string | null {
return this._accessToken();
}
async login(email: string, password: string): Promise<boolean> {
try {
const response = await firstValueFrom(
this.http.post<ILoginResponse>('/api/v1/auth/login', { email, password })
);
this._user.set(response.user);
this._accessToken.set(response.accessToken);
this._refreshToken.set(response.refreshToken);
this._sessionId.set(response.sessionId);
this.saveToStorage();
return true;
} catch (error) {
console.error('Login failed:', error);
return false;
}
}
async logout(): Promise<void> {
try {
const sessionId = this._sessionId();
if (sessionId) {
await firstValueFrom(
this.http.post('/api/v1/auth/logout', { sessionId })
).catch(() => {});
}
} finally {
this.clearAuth();
this.router.navigate(['/login']);
}
}
async refreshAccessToken(): Promise<boolean> {
const refreshToken = this._refreshToken();
if (!refreshToken) return false;
try {
const response = await firstValueFrom(
this.http.post<{ accessToken: string }>('/api/v1/auth/refresh', {
refreshToken,
})
);
this._accessToken.set(response.accessToken);
this.saveToStorage();
return true;
} catch {
this.clearAuth();
return false;
}
}
async fetchCurrentUser(): Promise<IUser | null> {
try {
const user = await firstValueFrom(
this.http.get<IUser>('/api/v1/auth/me')
);
this._user.set(user);
return user;
} catch {
return null;
}
}
private loadFromStorage(): void {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
const sessionId = localStorage.getItem('sessionId');
const userJson = localStorage.getItem('user');
if (accessToken) this._accessToken.set(accessToken);
if (refreshToken) this._refreshToken.set(refreshToken);
if (sessionId) this._sessionId.set(sessionId);
if (userJson) {
try {
this._user.set(JSON.parse(userJson));
} catch {}
}
}
private saveToStorage(): void {
const accessToken = this._accessToken();
const refreshToken = this._refreshToken();
const sessionId = this._sessionId();
const user = this._user();
if (accessToken) localStorage.setItem('accessToken', accessToken);
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
if (sessionId) localStorage.setItem('sessionId', sessionId);
if (user) localStorage.setItem('user', JSON.stringify(user));
}
private clearAuth(): void {
this._user.set(null);
this._accessToken.set(null);
this._refreshToken.set(null);
this._sessionId.set(null);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('sessionId');
localStorage.removeItem('user');
}
}

View File

@@ -0,0 +1,50 @@
import { Injectable, signal } from '@angular/core';
export interface IToast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
duration?: number;
}
@Injectable({ providedIn: 'root' })
export class ToastService {
private _toasts = signal<IToast[]>([]);
readonly toasts = this._toasts.asReadonly();
show(toast: Omit<IToast, 'id'>): void {
const id = crypto.randomUUID();
const newToast: IToast = { ...toast, id };
this._toasts.update((toasts) => [...toasts, newToast]);
const duration = toast.duration ?? 5000;
if (duration > 0) {
setTimeout(() => this.dismiss(id), duration);
}
}
success(title: string, message?: string): void {
this.show({ type: 'success', title, message });
}
error(title: string, message?: string): void {
this.show({ type: 'error', title, message });
}
warning(title: string, message?: string): void {
this.show({ type: 'warning', title, message });
}
info(title: string, message?: string): void {
this.show({ type: 'info', title, message });
}
dismiss(id: string): void {
this._toasts.update((toasts) => toasts.filter((t) => t.id !== id));
}
clear(): void {
this._toasts.set([]);
}
}

View File

@@ -0,0 +1,220 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ApiService, type IOrganization, type IPackage } from '../../core/services/api.service';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [RouterLink],
template: `
<div class="p-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">Welcome back, {{ userName() }}</p>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="card card-content">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Organizations</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organizations().length }}</p>
</div>
</div>
</div>
<div class="card card-content">
<div class="flex items-center gap-4">
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-lg">
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Packages</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ packages().length }}</p>
</div>
</div>
</div>
<div class="card card-content">
<div class="flex items-center gap-4">
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Downloads</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ totalDownloads() }}</p>
</div>
</div>
</div>
<div class="card card-content">
<div class="flex items-center gap-4">
<div class="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<svg class="w-6 h-6 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Last Activity</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">Today</p>
</div>
</div>
</div>
</div>
<!-- Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Recent Packages -->
<div class="card">
<div class="card-header flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Recent Packages</h2>
<a routerLink="/packages" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">View all</a>
</div>
<div class="card-content p-0">
@if (packages().length === 0) {
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
No packages yet
</div>
} @else {
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
@for (pkg of packages().slice(0, 5); track pkg.id) {
<li class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">{{ pkg.name }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ pkg.protocol }} · {{ pkg.latestVersion || 'No versions' }}</p>
</div>
<span class="badge-default">{{ pkg.downloadCount }} downloads</span>
</div>
</li>
}
</ul>
}
</div>
</div>
<!-- Organizations -->
<div class="card">
<div class="card-header flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Your Organizations</h2>
<a routerLink="/organizations" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">View all</a>
</div>
<div class="card-content p-0">
@if (organizations().length === 0) {
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
No organizations yet
</div>
} @else {
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
@for (org of organizations().slice(0, 5); track org.id) {
<li class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<a [routerLink]="['/organizations', org.id]" class="flex items-center gap-4">
<div class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ org.name.charAt(0).toUpperCase() }}
</span>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">{{ org.displayName }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ org.memberCount }} members</p>
</div>
</a>
</li>
}
</ul>
}
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Quick Actions</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<a routerLink="/organizations" class="card card-content flex items-center gap-3 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Create Organization</span>
</a>
<a routerLink="/tokens" class="card card-content flex items-center gap-3 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Generate API Token</span>
</a>
<a routerLink="/packages" class="card card-content flex items-center gap-3 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Search Packages</span>
</a>
<a routerLink="/settings" class="card card-content flex items-center gap-3 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Account Settings</span>
</a>
</div>
</div>
</div>
`,
})
export class DashboardComponent implements OnInit {
private authService = inject(AuthService);
private apiService = inject(ApiService);
organizations = signal<IOrganization[]>([]);
packages = signal<IPackage[]>([]);
totalDownloads = signal(0);
userName = () => this.authService.user()?.displayName || 'User';
ngOnInit(): void {
this.loadData();
}
private async loadData(): Promise<void> {
try {
const [orgsResponse, packagesResponse] = await Promise.all([
this.apiService.getOrganizations().toPromise(),
this.apiService.searchPackages({ limit: 10 }).toPromise(),
]);
this.organizations.set(orgsResponse?.organizations || []);
this.packages.set(packagesResponse?.packages || []);
const totalDownloads = (packagesResponse?.packages || []).reduce(
(sum, pkg) => sum + pkg.downloadCount,
0
);
this.totalDownloads.set(totalDownloads);
} catch (error) {
console.error('Failed to load dashboard data:', error);
}
}
}

View File

@@ -0,0 +1,121 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
<div class="max-w-md w-full">
<!-- Logo -->
<div class="text-center mb-8">
<div class="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Stack.Gallery Registry</h1>
<p class="text-gray-500 dark:text-gray-400 mt-2">Sign in to your account</p>
</div>
<!-- Login form -->
<form (ngSubmit)="login()" class="card p-6 space-y-6">
<div class="space-y-4">
<div>
<label for="email" class="label block mb-1.5">Email</label>
<input
type="email"
id="email"
[(ngModel)]="email"
name="email"
class="input"
placeholder="you@example.com"
required
autocomplete="email"
/>
</div>
<div>
<label for="password" class="label block mb-1.5">Password</label>
<input
type="password"
id="password"
[(ngModel)]="password"
name="password"
class="input"
placeholder="Enter your password"
required
autocomplete="current-password"
/>
</div>
</div>
@if (error()) {
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p class="text-sm text-red-600 dark:text-red-400">{{ error() }}</p>
</div>
}
<button
type="submit"
[disabled]="loading()"
class="btn-primary btn-md w-full"
>
@if (loading()) {
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
} @else {
Sign in
}
</button>
</form>
<p class="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
Enterprise Package Registry
</p>
</div>
</div>
`,
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
private toastService = inject(ToastService);
email = '';
password = '';
loading = signal(false);
error = signal<string | null>(null);
async login(): Promise<void> {
if (!this.email || !this.password) {
this.error.set('Please enter your email and password');
return;
}
this.loading.set(true);
this.error.set(null);
try {
const success = await this.authService.login(this.email, this.password);
if (success) {
this.toastService.success('Welcome back!');
this.router.navigate(['/dashboard']);
} else {
this.error.set('Invalid email or password');
}
} catch (err) {
this.error.set('An error occurred. Please try again.');
} finally {
this.loading.set(false);
}
}
}

View File

@@ -0,0 +1,151 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, type IOrganization, type IRepository } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-organization-detail',
standalone: true,
imports: [RouterLink],
template: `
<div class="p-6 max-w-7xl mx-auto">
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
} @else if (organization()) {
<!-- Header -->
<div class="flex items-start justify-between mb-8">
<div class="flex items-center gap-4">
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-xl flex items-center justify-center">
<span class="text-2xl font-medium text-gray-600 dark:text-gray-300">
{{ organization()!.name.charAt(0).toUpperCase() }}
</span>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.displayName }}</h1>
<p class="text-gray-500 dark:text-gray-400">&#64;{{ organization()!.name }}</p>
</div>
</div>
<div class="flex items-center gap-3">
@if (organization()!.isPublic) {
<span class="badge-default">Public</span>
} @else {
<span class="badge-warning">Private</span>
}
</div>
</div>
@if (organization()!.description) {
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ organization()!.description }}</p>
}
<!-- Repositories Section -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Repositories</h2>
<button class="btn-primary btn-sm">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
New Repository
</button>
</div>
@if (repositories().length === 0) {
<div class="card card-content text-center py-8">
<p class="text-gray-500 dark:text-gray-400">No repositories yet</p>
</div>
} @else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (repo of repositories(); track repo.id) {
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ repo.displayName }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ repo.name }}</p>
</div>
@if (repo.isPublic) {
<span class="badge-default">Public</span>
}
</div>
@if (repo.description) {
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 line-clamp-2">{{ repo.description }}</p>
}
<div class="mt-3 flex items-center gap-4">
<div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
{{ repo.packageCount }} packages
</div>
<div class="flex gap-1">
@for (protocol of repo.protocols; track protocol) {
<span class="badge-primary text-xs">{{ protocol }}</span>
}
</div>
</div>
</a>
}
</div>
}
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Members</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.memberCount }}</p>
</div>
<div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Repositories</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ repositories().length }}</p>
</div>
<div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Created</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ formatDate(organization()!.createdAt) }}</p>
</div>
</div>
}
</div>
`,
})
export class OrganizationDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private apiService = inject(ApiService);
private toastService = inject(ToastService);
organization = signal<IOrganization | null>(null);
repositories = signal<IRepository[]>([]);
loading = signal(true);
ngOnInit(): void {
const orgId = this.route.snapshot.paramMap.get('orgId');
if (orgId) {
this.loadData(orgId);
}
}
private async loadData(orgId: string): Promise<void> {
this.loading.set(true);
try {
const [org, reposResponse] = await Promise.all([
this.apiService.getOrganization(orgId).toPromise(),
this.apiService.getRepositories(orgId).toPromise(),
]);
this.organization.set(org || null);
this.repositories.set(reposResponse?.repositories || []);
} catch (error) {
this.toastService.error('Failed to load organization');
} finally {
this.loading.set(false);
}
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
}

View File

@@ -0,0 +1,210 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ApiService, type IOrganization } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-organizations',
standalone: true,
imports: [RouterLink, FormsModule],
template: `
<div class="p-6 max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Organizations</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">Manage your organizations and repositories</p>
</div>
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
New Organization
</button>
</div>
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
} @else if (organizations().length === 0) {
<div class="card card-content text-center py-12">
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No organizations yet</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Create your first organization to start managing packages</p>
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
Create Organization
</button>
</div>
} @else {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@for (org of organizations(); track org.id) {
<a [routerLink]="['/organizations', org.id]" class="card hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<div class="card-content">
<div class="flex items-start gap-4">
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="text-lg font-medium text-gray-600 dark:text-gray-300">
{{ org.name.charAt(0).toUpperCase() }}
</span>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 truncate">{{ org.displayName }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">&#64;{{ org.name }}</p>
</div>
@if (org.isPublic) {
<span class="badge-default">Public</span>
} @else {
<span class="badge-warning">Private</span>
}
</div>
@if (org.description) {
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{{ org.description }}</p>
}
<div class="mt-4 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{{ org.memberCount }} members
</span>
</div>
</div>
</a>
}
</div>
}
<!-- Create Modal -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="card w-full max-w-md mx-4">
<div class="card-header flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Create Organization</h2>
<button (click)="showCreateModal.set(false)" class="btn-ghost btn-sm p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form (ngSubmit)="createOrganization()" class="card-content space-y-4">
<div>
<label class="label block mb-1.5">Name</label>
<input
type="text"
[(ngModel)]="newOrg.name"
name="name"
class="input"
placeholder="my-organization"
required
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
/>
<p class="text-xs text-gray-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
</div>
<div>
<label class="label block mb-1.5">Display Name</label>
<input
type="text"
[(ngModel)]="newOrg.displayName"
name="displayName"
class="input"
placeholder="My Organization"
/>
</div>
<div>
<label class="label block mb-1.5">Description</label>
<textarea
[(ngModel)]="newOrg.description"
name="description"
class="input min-h-[80px]"
placeholder="Optional description..."
></textarea>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
[(ngModel)]="newOrg.isPublic"
name="isPublic"
id="isPublic"
class="w-4 h-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label for="isPublic" class="text-sm text-gray-700 dark:text-gray-300">Make this organization public</label>
</div>
</form>
<div class="card-footer flex justify-end gap-3">
<button (click)="showCreateModal.set(false)" class="btn-secondary btn-md">Cancel</button>
<button (click)="createOrganization()" [disabled]="creating()" class="btn-primary btn-md">
@if (creating()) {
Creating...
} @else {
Create
}
</button>
</div>
</div>
</div>
}
</div>
`,
})
export class OrganizationsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
organizations = signal<IOrganization[]>([]);
loading = signal(true);
showCreateModal = signal(false);
creating = signal(false);
newOrg = {
name: '',
displayName: '',
description: '',
isPublic: false,
};
ngOnInit(): void {
this.loadOrganizations();
}
private async loadOrganizations(): Promise<void> {
this.loading.set(true);
try {
const response = await this.apiService.getOrganizations().toPromise();
this.organizations.set(response?.organizations || []);
} catch (error) {
this.toastService.error('Failed to load organizations');
} finally {
this.loading.set(false);
}
}
async createOrganization(): Promise<void> {
if (!this.newOrg.name) return;
this.creating.set(true);
try {
const org = await this.apiService.createOrganization({
name: this.newOrg.name,
displayName: this.newOrg.displayName || this.newOrg.name,
description: this.newOrg.description,
isPublic: this.newOrg.isPublic,
}).toPromise();
if (org) {
this.organizations.update((orgs) => [...orgs, org]);
this.toastService.success('Organization created successfully');
this.showCreateModal.set(false);
this.newOrg = { name: '', displayName: '', description: '', isPublic: false };
}
} catch (error) {
this.toastService.error('Failed to create organization');
} finally {
this.creating.set(false);
}
}
}

View File

@@ -0,0 +1,186 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, type IPackage } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-package-detail',
standalone: true,
imports: [RouterLink],
template: `
<div class="p-6 max-w-7xl mx-auto">
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
} @else if (pkg()) {
<div class="mb-6">
<a routerLink="/packages" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
&larr; Back to packages
</a>
</div>
<div class="flex items-start justify-between mb-8">
<div>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ pkg()!.name }}</h1>
<span class="badge-primary">{{ pkg()!.protocol }}</span>
@if (pkg()!.isPrivate) {
<span class="badge-warning">Private</span>
}
</div>
@if (pkg()!.description) {
<p class="text-gray-500 dark:text-gray-400 mt-2">{{ pkg()!.description }}</p>
}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main content -->
<div class="lg:col-span-2 space-y-6">
<!-- Installation -->
<div class="card">
<div class="card-header">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Installation</h2>
</div>
<div class="card-content">
@switch (pkg()!.protocol) {
@case ('npm') {
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
npm install {{ pkg()!.name }}
</code>
}
@case ('oci') {
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
docker pull registry.stack.gallery/{{ pkg()!.name }}:{{ pkg()!.latestVersion || 'latest' }}
</code>
}
@case ('maven') {
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
&lt;dependency&gt;<br/>
&nbsp;&nbsp;&lt;groupId&gt;{{ pkg()!.name.split(':')[0] }}&lt;/groupId&gt;<br/>
&nbsp;&nbsp;&lt;artifactId&gt;{{ pkg()!.name.split(':')[1] || pkg()!.name }}&lt;/artifactId&gt;<br/>
&nbsp;&nbsp;&lt;version&gt;{{ pkg()!.latestVersion || 'LATEST' }}&lt;/version&gt;<br/>
&lt;/dependency&gt;
</code>
}
@case ('pypi') {
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
pip install {{ pkg()!.name }}
</code>
}
@case ('cargo') {
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
cargo add {{ pkg()!.name }}
</code>
}
@case ('composer') {
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
composer require {{ pkg()!.name }}
</code>
}
@case ('rubygems') {
<code class="block bg-gray-100 dark:bg-gray-900 p-4 rounded-lg text-sm font-mono">
gem install {{ pkg()!.name }}
</code>
}
@default {
<p class="text-gray-500 dark:text-gray-400">Installation instructions not available</p>
}
}
</div>
</div>
<!-- Versions -->
<div class="card">
<div class="card-header">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Versions</h2>
</div>
<div class="card-content p-0">
@if (versions().length === 0) {
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
No versions published yet
</div>
} @else {
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
@for (version of versions(); track version.version) {
<li class="px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-mono text-sm">{{ version.version }}</span>
@if (version.version === pkg()!.latestVersion) {
<span class="badge-success">latest</span>
}
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ version.downloads }} downloads
</span>
</li>
}
</ul>
}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<div class="card card-content">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Stats</h3>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Downloads</dt>
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ pkg()!.downloadCount }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Latest version</dt>
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ pkg()!.latestVersion || 'N/A' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Last updated</dt>
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(pkg()!.updatedAt) }}</dd>
</div>
</dl>
</div>
</div>
</div>
}
</div>
`,
})
export class PackageDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private apiService = inject(ApiService);
private toastService = inject(ToastService);
pkg = signal<IPackage | null>(null);
versions = signal<{ version: string; downloads: number }[]>([]);
loading = signal(true);
ngOnInit(): void {
const packageId = this.route.snapshot.paramMap.get('packageId');
if (packageId) {
this.loadPackage(packageId);
}
}
private async loadPackage(packageId: string): Promise<void> {
this.loading.set(true);
try {
const pkg = await this.apiService.getPackage(packageId).toPromise();
this.pkg.set(pkg || null);
// Versions would come from the full package response in a real implementation
this.versions.set([]);
} catch (error) {
this.toastService.error('Failed to load package');
} finally {
this.loading.set(false);
}
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
}

View File

@@ -0,0 +1,179 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ApiService, type IPackage } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-packages',
standalone: true,
imports: [RouterLink, FormsModule],
template: `
<div class="p-6 max-w-7xl mx-auto">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Packages</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">Browse and search all available packages</p>
</div>
<!-- Search and Filters -->
<div class="card card-content mb-6">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<input
type="text"
[(ngModel)]="searchQuery"
(ngModelChange)="search()"
class="input"
placeholder="Search packages..."
/>
</div>
<select
[(ngModel)]="selectedProtocol"
(ngModelChange)="search()"
class="input w-full sm:w-40"
>
<option value="">All protocols</option>
<option value="npm">npm</option>
<option value="oci">OCI</option>
<option value="maven">Maven</option>
<option value="cargo">Cargo</option>
<option value="composer">Composer</option>
<option value="pypi">PyPI</option>
<option value="rubygems">RubyGems</option>
</select>
</div>
</div>
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
} @else if (packages().length === 0) {
<div class="card card-content text-center py-12">
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No packages found</h3>
<p class="text-gray-500 dark:text-gray-400">
@if (searchQuery || selectedProtocol) {
Try adjusting your search or filters
} @else {
Publish your first package to get started
}
</p>
</div>
} @else {
<div class="card">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
@for (pkg of packages(); track pkg.id) {
<li>
<a [routerLink]="['/packages', pkg.id]" class="block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-gray-900 dark:text-gray-100 truncate">{{ pkg.name }}</h3>
<span class="badge-primary">{{ pkg.protocol }}</span>
@if (pkg.isPrivate) {
<span class="badge-warning">Private</span>
}
</div>
@if (pkg.description) {
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">{{ pkg.description }}</p>
}
<div class="flex items-center gap-4 mt-2 text-sm text-gray-500 dark:text-gray-400">
<span>{{ pkg.latestVersion || 'No versions' }}</span>
<span>{{ pkg.downloadCount }} downloads</span>
<span>Updated {{ formatDate(pkg.updatedAt) }}</span>
</div>
</div>
<svg class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</a>
</li>
}
</ul>
</div>
@if (total() > packages().length) {
<div class="mt-6 text-center">
<button (click)="loadMore()" class="btn-secondary btn-md">
Load more
</button>
</div>
}
}
</div>
`,
})
export class PackagesComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
packages = signal<IPackage[]>([]);
total = signal(0);
loading = signal(true);
searchQuery = '';
selectedProtocol = '';
private offset = 0;
private readonly limit = 20;
private searchTimeout?: ReturnType<typeof setTimeout>;
ngOnInit(): void {
this.loadPackages();
}
search(): void {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.offset = 0;
this.loadPackages();
}, 300);
}
async loadPackages(): Promise<void> {
this.loading.set(true);
try {
const response = await this.apiService.searchPackages({
q: this.searchQuery || undefined,
protocol: this.selectedProtocol || undefined,
limit: this.limit,
offset: this.offset,
}).toPromise();
if (this.offset === 0) {
this.packages.set(response?.packages || []);
} else {
this.packages.update((pkgs) => [...pkgs, ...(response?.packages || [])]);
}
this.total.set(response?.total || 0);
} catch (error) {
this.toastService.error('Failed to load packages');
} finally {
this.loading.set(false);
}
}
loadMore(): void {
this.offset += this.limit;
this.loadPackages();
}
formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
return date.toLocaleDateString();
}
}

View File

@@ -0,0 +1,121 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, type IRepository, type IPackage } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-repository-detail',
standalone: true,
imports: [RouterLink],
template: `
<div class="p-6 max-w-7xl mx-auto">
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
} @else if (repository()) {
<div class="mb-6">
<a [routerLink]="['/organizations', repository()!.organizationId]" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
&larr; Back to organization
</a>
</div>
<div class="flex items-start justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ repository()!.displayName }}</h1>
<p class="text-gray-500 dark:text-gray-400">{{ repository()!.name }}</p>
</div>
<div class="flex items-center gap-2">
@for (protocol of repository()!.protocols; track protocol) {
<span class="badge-primary">{{ protocol }}</span>
}
@if (repository()!.isPublic) {
<span class="badge-default">Public</span>
}
</div>
</div>
@if (repository()!.description) {
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ repository()!.description }}</p>
}
<!-- Packages -->
<div class="card">
<div class="card-header">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Packages</h2>
</div>
<div class="card-content p-0">
@if (packages().length === 0) {
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No packages in this repository yet
</div>
} @else {
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
@for (pkg of packages(); track pkg.id) {
<li class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">{{ pkg.name }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ pkg.protocol }} · {{ pkg.latestVersion || 'No versions' }}
</p>
</div>
<div class="flex items-center gap-4">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ pkg.downloadCount }} downloads
</span>
<a [routerLink]="['/packages', pkg.id]" class="btn-ghost btn-sm">
View
</a>
</div>
</div>
</li>
}
</ul>
}
</div>
</div>
}
</div>
`,
})
export class RepositoryDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private apiService = inject(ApiService);
private toastService = inject(ToastService);
repository = signal<IRepository | null>(null);
packages = signal<IPackage[]>([]);
loading = signal(true);
ngOnInit(): void {
const repoId = this.route.snapshot.paramMap.get('repoId');
if (repoId) {
this.loadData(repoId);
}
}
private async loadData(repoId: string): Promise<void> {
this.loading.set(true);
try {
const repo = await this.apiService.getRepository(repoId).toPromise();
this.repository.set(repo || null);
if (repo) {
const packagesResponse = await this.apiService.searchPackages({
organizationId: repo.organizationId,
}).toPromise();
this.packages.set(
(packagesResponse?.packages || []).filter((p) => p.repositoryId === repoId)
);
}
} catch (error) {
this.toastService.error('Failed to load repository');
} finally {
this.loading.set(false);
}
}
}

View File

@@ -0,0 +1,218 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AuthService, type IUser } from '../../core/services/auth.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-settings',
standalone: true,
imports: [FormsModule],
template: `
<div class="p-6 max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Account Settings</h1>
<!-- Profile Section -->
<div class="card mb-6">
<div class="card-header">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Profile</h2>
</div>
<div class="card-content space-y-4">
<div class="flex items-center gap-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span class="text-2xl font-medium text-gray-600 dark:text-gray-300">
{{ userInitial() }}
</span>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">{{ user()?.displayName }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ user()?.email }}</p>
</div>
</div>
<div>
<label class="label block mb-1.5">Display Name</label>
<input
type="text"
[(ngModel)]="displayName"
class="input"
/>
</div>
<div>
<label class="label block mb-1.5">Username</label>
<input
type="text"
[value]="user()?.username"
class="input bg-gray-50 dark:bg-gray-900"
disabled
/>
<p class="text-xs text-gray-500 mt-1">Username cannot be changed</p>
</div>
<div>
<label class="label block mb-1.5">Email</label>
<input
type="email"
[value]="user()?.email"
class="input bg-gray-50 dark:bg-gray-900"
disabled
/>
<p class="text-xs text-gray-500 mt-1">Contact support to change your email</p>
</div>
</div>
<div class="card-footer flex justify-end">
<button (click)="saveProfile()" [disabled]="saving()" class="btn-primary btn-md">
@if (saving()) {
Saving...
} @else {
Save Changes
}
</button>
</div>
</div>
<!-- Security Section -->
<div class="card mb-6">
<div class="card-header">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Security</h2>
</div>
<div class="card-content space-y-4">
<div>
<label class="label block mb-1.5">Current Password</label>
<input
type="password"
[(ngModel)]="currentPassword"
class="input"
placeholder="Enter current password"
/>
</div>
<div>
<label class="label block mb-1.5">New Password</label>
<input
type="password"
[(ngModel)]="newPassword"
class="input"
placeholder="Enter new password"
/>
</div>
<div>
<label class="label block mb-1.5">Confirm New Password</label>
<input
type="password"
[(ngModel)]="confirmPassword"
class="input"
placeholder="Confirm new password"
/>
</div>
</div>
<div class="card-footer flex justify-end">
<button (click)="changePassword()" [disabled]="changingPassword()" class="btn-primary btn-md">
@if (changingPassword()) {
Changing...
} @else {
Change Password
}
</button>
</div>
</div>
<!-- Sessions Section -->
<div class="card">
<div class="card-header flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Sessions</h2>
</div>
<div class="card-content">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Sign out of all other browser sessions. This will not affect your current session.
</p>
<button (click)="logoutAllSessions()" class="btn-secondary btn-md">
Sign out other sessions
</button>
</div>
</div>
<!-- Danger Zone -->
<div class="card mt-6 border-red-200 dark:border-red-800">
<div class="card-header bg-red-50 dark:bg-red-900/20">
<h2 class="font-semibold text-red-700 dark:text-red-400">Danger Zone</h2>
</div>
<div class="card-content">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Once you delete your account, there is no going back. Please be certain.
</p>
<button class="btn-md bg-red-600 text-white hover:bg-red-700">
Delete Account
</button>
</div>
</div>
</div>
`,
})
export class SettingsComponent implements OnInit {
private authService = inject(AuthService);
private toastService = inject(ToastService);
user = this.authService.user;
displayName = '';
currentPassword = '';
newPassword = '';
confirmPassword = '';
saving = signal(false);
changingPassword = signal(false);
userInitial = () => {
const name = this.user()?.displayName || 'U';
return name.charAt(0).toUpperCase();
};
ngOnInit(): void {
this.displayName = this.user()?.displayName || '';
}
async saveProfile(): Promise<void> {
this.saving.set(true);
try {
// Would call API to update profile
this.toastService.success('Profile updated');
} catch (error) {
this.toastService.error('Failed to update profile');
} finally {
this.saving.set(false);
}
}
async changePassword(): Promise<void> {
if (!this.currentPassword || !this.newPassword) {
this.toastService.error('Please fill in all password fields');
return;
}
if (this.newPassword !== this.confirmPassword) {
this.toastService.error('New passwords do not match');
return;
}
this.changingPassword.set(true);
try {
// Would call API to change password
this.toastService.success('Password changed');
this.currentPassword = '';
this.newPassword = '';
this.confirmPassword = '';
} catch (error) {
this.toastService.error('Failed to change password');
} finally {
this.changingPassword.set(false);
}
}
async logoutAllSessions(): Promise<void> {
try {
// Would call API to logout all sessions
this.toastService.success('Signed out of all other sessions');
} catch (error) {
this.toastService.error('Failed to sign out');
}
}
}

View File

@@ -0,0 +1,280 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ApiService, type IToken } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-tokens',
standalone: true,
imports: [FormsModule],
template: `
<div class="p-6 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">API Tokens</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">Manage your API tokens for registry access</p>
</div>
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
New Token
</button>
</div>
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
} @else if (tokens().length === 0) {
<div class="card card-content text-center py-12">
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">No API tokens</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Create a token to authenticate with the registry</p>
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">Create Token</button>
</div>
} @else {
<div class="card">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
@for (token of tokens(); track token.id) {
<li class="px-6 py-4">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3">
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ token.name }}</h3>
@for (protocol of token.protocols.slice(0, 3); track protocol) {
<span class="badge-primary text-xs">{{ protocol }}</span>
}
@if (token.protocols.length > 3) {
<span class="badge-default text-xs">+{{ token.protocols.length - 3 }}</span>
}
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<code class="font-mono">{{ token.tokenPrefix }}...</code>
@if (token.expiresAt) {
<span class="mx-2">·</span>
<span>Expires {{ formatDate(token.expiresAt) }}</span>
}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Created {{ formatDate(token.createdAt) }}
@if (token.lastUsedAt) {
· Last used {{ formatDate(token.lastUsedAt) }}
}
· {{ token.usageCount }} uses
</p>
</div>
<button (click)="revokeToken(token)" class="btn-ghost btn-sm text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20">
Revoke
</button>
</div>
</li>
}
</ul>
</div>
}
<!-- Create Modal -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="card w-full max-w-lg mx-4">
<div class="card-header flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Create API Token</h2>
<button (click)="closeCreateModal()" class="btn-ghost btn-sm p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="card-content space-y-4">
<div>
<label class="label block mb-1.5">Token Name</label>
<input
type="text"
[(ngModel)]="newToken.name"
class="input"
placeholder="my-ci-token"
/>
</div>
<div>
<label class="label block mb-1.5">Protocols</label>
<div class="flex flex-wrap gap-2">
@for (protocol of availableProtocols; track protocol) {
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
[class.bg-primary-50]="newToken.protocols.includes(protocol)"
[class.border-primary-300]="newToken.protocols.includes(protocol)"
[class.dark:bg-primary-900/20]="newToken.protocols.includes(protocol)">
<input
type="checkbox"
[checked]="newToken.protocols.includes(protocol)"
(change)="toggleProtocol(protocol)"
class="sr-only"
/>
<span class="text-sm">{{ protocol }}</span>
</label>
}
</div>
</div>
<div>
<label class="label block mb-1.5">Expiration (optional)</label>
<select [(ngModel)]="newToken.expiresInDays" class="input">
<option [ngValue]="null">Never expires</option>
<option [ngValue]="7">7 days</option>
<option [ngValue]="30">30 days</option>
<option [ngValue]="90">90 days</option>
<option [ngValue]="365">1 year</option>
</select>
</div>
</div>
<div class="card-footer flex justify-end gap-3">
<button (click)="closeCreateModal()" class="btn-secondary btn-md">Cancel</button>
<button (click)="createToken()" [disabled]="creating() || !newToken.name || newToken.protocols.length === 0" class="btn-primary btn-md">
@if (creating()) {
Creating...
} @else {
Create Token
}
</button>
</div>
</div>
</div>
}
<!-- Token Created Modal -->
@if (createdToken()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="card w-full max-w-lg mx-4">
<div class="card-header">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Token Created</h2>
</div>
<div class="card-content space-y-4">
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-2">
Make sure to copy your token now. You won't be able to see it again!
</p>
</div>
<div>
<label class="label block mb-1.5">Your new token:</label>
<div class="flex gap-2">
<code class="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-900 rounded-md text-sm font-mono overflow-x-auto">
{{ createdToken() }}
</code>
<button (click)="copyToken()" class="btn-secondary btn-md">
Copy
</button>
</div>
</div>
</div>
<div class="card-footer flex justify-end">
<button (click)="createdToken.set(null)" class="btn-primary btn-md">Done</button>
</div>
</div>
</div>
}
</div>
`,
})
export class TokensComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
tokens = signal<IToken[]>([]);
loading = signal(true);
showCreateModal = signal(false);
creating = signal(false);
createdToken = signal<string | null>(null);
availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
newToken = {
name: '',
protocols: [] as string[],
expiresInDays: null as number | null,
};
ngOnInit(): void {
this.loadTokens();
}
private async loadTokens(): Promise<void> {
this.loading.set(true);
try {
const response = await this.apiService.getTokens().toPromise();
this.tokens.set(response?.tokens || []);
} catch (error) {
this.toastService.error('Failed to load tokens');
} finally {
this.loading.set(false);
}
}
toggleProtocol(protocol: string): void {
if (this.newToken.protocols.includes(protocol)) {
this.newToken.protocols = this.newToken.protocols.filter((p) => p !== protocol);
} else {
this.newToken.protocols = [...this.newToken.protocols, protocol];
}
}
async createToken(): Promise<void> {
if (!this.newToken.name || this.newToken.protocols.length === 0) return;
this.creating.set(true);
try {
const response = await this.apiService.createToken({
name: this.newToken.name,
protocols: this.newToken.protocols,
scopes: this.newToken.protocols.map((p) => ({
protocol: p,
actions: ['read', 'write'],
})),
expiresInDays: this.newToken.expiresInDays || undefined,
}).toPromise();
if (response) {
this.createdToken.set(response.token);
this.tokens.update((tokens) => [response, ...tokens]);
this.showCreateModal.set(false);
this.newToken = { name: '', protocols: [], expiresInDays: null };
}
} catch (error) {
this.toastService.error('Failed to create token');
} finally {
this.creating.set(false);
}
}
async revokeToken(token: IToken): Promise<void> {
if (!confirm(`Are you sure you want to revoke "${token.name}"? This cannot be undone.`)) return;
try {
await this.apiService.revokeToken(token.id).toPromise();
this.tokens.update((tokens) => tokens.filter((t) => t.id !== token.id));
this.toastService.success('Token revoked');
} catch (error) {
this.toastService.error('Failed to revoke token');
}
}
closeCreateModal(): void {
this.showCreateModal.set(false);
this.newToken = { name: '', protocols: [], expiresInDays: null };
}
copyToken(): void {
const token = this.createdToken();
if (token) {
navigator.clipboard.writeText(token);
this.toastService.success('Token copied to clipboard');
}
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
}

View File

@@ -0,0 +1,115 @@
import { Component, computed, inject } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service';
import { ToastService } from '../../../core/services/toast.service';
@Component({
selector: 'app-layout',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="min-h-screen flex">
<!-- Sidebar -->
<aside class="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<!-- Logo -->
<div class="h-16 flex items-center px-6 border-b border-gray-200 dark:border-gray-700">
<a routerLink="/" class="flex items-center gap-2">
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<span class="font-semibold text-lg">Stack.Gallery</span>
</a>
</div>
<!-- Navigation -->
<nav class="flex-1 p-4 space-y-1">
<a routerLink="/dashboard" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a>
<a routerLink="/organizations" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
Organizations
</a>
<a routerLink="/packages" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
Packages
</a>
<a routerLink="/tokens" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
API Tokens
</a>
<a routerLink="/settings" routerLinkActive="bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
</nav>
<!-- User section -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ userInitial() }}
</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ userName() }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
{{ userEmail() }}
</p>
</div>
<button (click)="logout()" class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 bg-gray-50 dark:bg-gray-900 overflow-auto">
<router-outlet />
</main>
</div>
`,
})
export class LayoutComponent {
private authService = inject(AuthService);
userName = computed(() => this.authService.user()?.displayName || 'User');
userEmail = computed(() => this.authService.user()?.email || '');
userInitial = computed(() => {
const name = this.authService.user()?.displayName || 'U';
return name.charAt(0).toUpperCase();
});
logout(): void {
this.authService.logout();
}
}

17
ui/src/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<title>Stack.Gallery Registry</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Enterprise-grade multi-protocol package registry">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body class="h-full bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 antialiased">
<app-root></app-root>
</body>
</html>

7
ui/src/main.ts Normal file
View File

@@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);

144
ui/src/styles.css Normal file
View File

@@ -0,0 +1,144 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 199 89% 48%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 199 89% 48%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 199 89% 48%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 199 89% 48%;
}
}
@layer base {
* {
@apply border-gray-200 dark:border-gray-800;
}
body {
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500;
}
.btn-secondary {
@apply btn bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700;
}
.btn-ghost {
@apply btn hover:bg-gray-100 dark:hover:bg-gray-800;
}
.btn-sm {
@apply h-8 px-3 text-xs;
}
.btn-md {
@apply h-10 px-4;
}
.btn-lg {
@apply h-12 px-6;
}
.input {
@apply flex h-10 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800
px-3 py-2 text-sm placeholder:text-gray-400 dark:placeholder:text-gray-500
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
disabled:cursor-not-allowed disabled:opacity-50;
}
.label {
@apply text-sm font-medium text-gray-700 dark:text-gray-300;
}
.card {
@apply rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-800 shadow-sm;
}
.card-header {
@apply px-6 py-4 border-b border-gray-200 dark:border-gray-700;
}
.card-content {
@apply px-6 py-4;
}
.card-footer {
@apply px-6 py-4 border-t border-gray-200 dark:border-gray-700;
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
}
.badge-default {
@apply badge bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200;
}
.badge-primary {
@apply badge bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200;
}
.badge-success {
@apply badge bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
}
.badge-warning {
@apply badge bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
}
.badge-destructive {
@apply badge bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
}
}

44
ui/tailwind.config.js Normal file
View File

@@ -0,0 +1,44 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{html,ts}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
accent: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
950: '#4a044e',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [],
}

9
ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

28
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"],
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}