feat: implement account settings and API tokens management

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

View File

@@ -0,0 +1,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;
}
}
}