fix(registry): align registry integrations with updated auth, storage, repository, and audit models

This commit is contained in:
2026-03-20 14:14:39 +00:00
parent fe3cb75095
commit d71ae08645
18 changed files with 451 additions and 523 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@stack.gallery/registry',
version: '1.4.1',
version: '1.4.2',
description: 'Enterprise-grade multi-protocol package registry'
}

View File

@@ -109,7 +109,7 @@ export class AdminAuthApi {
},
attributeMapping: body.attributeMapping,
provisioning: body.provisioning,
createdById: ctx.actor!.userId,
createdById: ctx.actor!.userId!,
});
} else if (body.type === 'ldap' && body.ldapConfig) {
// Encrypt bind password
@@ -124,7 +124,7 @@ export class AdminAuthApi {
},
attributeMapping: body.attributeMapping,
provisioning: body.provisioning,
createdById: ctx.actor!.userId,
createdById: ctx.actor!.userId!,
});
} else {
return {
@@ -138,11 +138,10 @@ export class AdminAuthApi {
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('ORGANIZATION_CREATED', 'system', {
}).log('AUTH_PROVIDER_CREATED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: {
action: 'auth_provider_created',
providerName: provider.name,
providerType: provider.type,
},
@@ -270,11 +269,10 @@ export class AdminAuthApi {
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('ORGANIZATION_UPDATED', 'system', {
}).log('AUTH_PROVIDER_UPDATED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: {
action: 'auth_provider_updated',
providerName: provider.name,
},
});
@@ -321,11 +319,10 @@ export class AdminAuthApi {
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('ORGANIZATION_DELETED', 'system', {
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: {
action: 'auth_provider_disabled',
providerName: provider.name,
},
});
@@ -360,11 +357,10 @@ export class AdminAuthApi {
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('ORGANIZATION_UPDATED', 'system', {
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
resourceId: id,
success: result.success,
metadata: {
action: 'auth_provider_tested',
result: result.success ? 'success' : 'failure',
latencyMs: result.latencyMs,
error: result.error,
@@ -433,12 +429,9 @@ export class AdminAuthApi {
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('ORGANIZATION_UPDATED', 'system', {
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
resourceId: 'platform-settings',
success: true,
metadata: {
action: 'platform_settings_updated',
},
});
return {

View File

@@ -39,7 +39,13 @@ export class OrganizationApi {
if (ctx.actor.user?.isSystemAdmin) {
organizations = await Organization.getInstances({});
} else {
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
const memberships = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
const orgs: Organization[] = [];
for (const m of memberships) {
const org = await Organization.findById(m.organizationId);
if (org) orgs.push(org);
}
organizations = orgs;
}
return {
@@ -155,8 +161,8 @@ export class OrganizationApi {
membership.organizationId = org.id;
membership.userId = ctx.actor.userId;
membership.role = 'owner';
membership.addedById = ctx.actor.userId;
membership.addedAt = new Date();
membership.invitedBy = ctx.actor.userId;
membership.joinedAt = new Date();
await membership.save();
@@ -310,7 +316,7 @@ export class OrganizationApi {
return {
userId: m.userId,
role: m.role,
addedAt: m.addedAt,
addedAt: m.joinedAt,
user: user
? {
username: user.username,
@@ -384,8 +390,8 @@ export class OrganizationApi {
membership.organizationId = org.id;
membership.userId = userId;
membership.role = role;
membership.addedById = ctx.actor.userId;
membership.addedAt = new Date();
membership.invitedBy = ctx.actor.userId;
membership.joinedAt = new Date();
await membership.save();
@@ -398,7 +404,7 @@ export class OrganizationApi {
body: {
userId: membership.userId,
role: membership.role,
addedAt: membership.addedAt,
addedAt: membership.joinedAt,
},
};
} catch (error) {

View File

@@ -174,7 +174,7 @@ export class PackageApi {
publishedAt: data.publishedAt,
size: data.size,
downloads: data.downloads,
checksum: data.checksum,
checksum: data.metadata?.checksum,
}));
return {

View File

@@ -6,7 +6,7 @@ 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';
import type { TRegistryProtocol, TRepositoryVisibility } from '../../interfaces/auth.interfaces.ts';
export class RepositoryApi {
private permissionService: PermissionService;
@@ -26,7 +26,6 @@ export class RepositoryApi {
const { orgId } = ctx.params;
try {
// Get accessible repositories
const repositories = await this.permissionService.getAccessibleRepositories(
ctx.actor.userId,
orgId
@@ -38,9 +37,9 @@ export class RepositoryApi {
repositories: repositories.map((repo) => ({
id: repo.id,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
protocol: repo.protocol,
visibility: repo.visibility,
isPublic: repo.isPublic,
packageCount: repo.packageCount,
createdAt: repo.createdAt,
@@ -84,11 +83,10 @@ export class RepositoryApi {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
protocol: repo.protocol,
visibility: repo.visibility,
isPublic: repo.isPublic,
settings: repo.settings,
packageCount: repo.packageCount,
storageBytes: repo.storageBytes,
createdAt: repo.createdAt,
@@ -118,17 +116,22 @@ export class RepositoryApi {
try {
const body = await ctx.request.json();
const { name, displayName, description, protocols, isPublic, settings } = body;
const { name, description, protocol, visibility } = body as {
name: string;
description?: string;
protocol?: TRegistryProtocol;
visibility?: TRepositoryVisibility;
};
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)) {
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' },
body: { error: 'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores' },
};
}
@@ -138,30 +141,15 @@ export class RepositoryApi {
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();
// Create repository using the model's factory method
const repo = await Repository.createRepository({
organizationId: orgId,
name,
description,
protocol: protocol || 'npm',
visibility: visibility || 'private',
createdById: ctx.actor.userId,
});
// Audit log
await AuditService.withContext({
@@ -177,9 +165,9 @@ export class RepositoryApi {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
protocol: repo.protocol,
visibility: repo.visibility,
isPublic: repo.isPublic,
createdAt: repo.createdAt,
},
@@ -217,13 +205,13 @@ export class RepositoryApi {
}
const body = await ctx.request.json();
const { displayName, description, protocols, isPublic, settings } = body;
const { description, visibility } = body as {
description?: string;
visibility?: TRepositoryVisibility;
};
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 };
if (visibility !== undefined) repo.visibility = visibility;
await repo.save();
@@ -232,11 +220,10 @@ export class RepositoryApi {
body: {
id: repo.id,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
protocol: repo.protocol,
visibility: repo.visibility,
isPublic: repo.isPublic,
settings: repo.settings,
},
};
} catch (error) {

View File

@@ -137,8 +137,8 @@ export class UserApi {
user.username = username;
user.passwordHash = passwordHash;
user.displayName = displayName || username;
user.isSystemAdmin = isSystemAdmin || false;
user.isActive = true;
user.isPlatformAdmin = isSystemAdmin || false;
user.status = 'active';
user.createdAt = new Date();
await user.save();
@@ -189,8 +189,8 @@ export class UserApi {
// Only admins can change these
if (ctx.actor.user?.isSystemAdmin) {
if (isActive !== undefined) user.isActive = isActive;
if (isSystemAdmin !== undefined) user.isSystemAdmin = isSystemAdmin;
if (isActive !== undefined) user.status = isActive ? 'active' : 'suspended';
if (isSystemAdmin !== undefined) user.isPlatformAdmin = isSystemAdmin;
}
// Password change
@@ -245,7 +245,7 @@ export class UserApi {
}
// Soft delete - deactivate instead of removing
user.isActive = false;
user.status = 'suspended';
await user.save();
return {

View File

@@ -51,6 +51,13 @@ export type TAuditAction =
| 'PACKAGE_PULLED'
| 'PACKAGE_DELETED'
| 'PACKAGE_DEPRECATED'
// Auth Provider Management
| 'AUTH_PROVIDER_CREATED'
| 'AUTH_PROVIDER_UPDATED'
| 'AUTH_PROVIDER_DELETED'
| 'AUTH_PROVIDER_TESTED'
// Platform Settings
| 'PLATFORM_SETTINGS_UPDATED'
// Security Events
| 'SECURITY_SCAN_COMPLETED'
| 'SECURITY_VULNERABILITY_FOUND'
@@ -65,6 +72,8 @@ export type TAuditResourceType =
| 'package'
| 'api_token'
| 'session'
| 'auth_provider'
| 'platform_settings'
| 'system';
// =============================================================================

View File

@@ -99,6 +99,16 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
return perm;
}
/**
* Find permission for a user on a repository (alias for getUserPermission)
*/
public static async findPermission(
repositoryId: string,
userId: string
): Promise<RepositoryPermission | null> {
return await RepositoryPermission.getUserPermission(repositoryId, userId);
}
/**
* Get user's direct permission on repository
*/

View File

@@ -39,6 +39,12 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
@plugins.smartdata.svDb()
public starCount: number = 0;
@plugins.smartdata.svDb()
public packageCount: number = 0;
@plugins.smartdata.svDb()
public storageBytes: number = 0;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@@ -128,6 +134,20 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
return await Repository.getInstances(query);
}
/**
* Whether this repository is public
*/
public get isPublic(): boolean {
return this.visibility === 'public';
}
/**
* Find repository by ID
*/
public static async findById(id: string): Promise<Repository | null> {
return await Repository.getInstance({ id });
}
/**
* Increment download count
*/

View File

@@ -25,6 +25,9 @@ export class Team extends plugins.smartdata.SmartDataDbDoc<Team, Team> implement
@plugins.smartdata.svDb()
public isDefaultTeam: boolean = false;
@plugins.smartdata.svDb()
public repositoryIds: string[] = [];
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();

View File

@@ -46,206 +46,114 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
}
/**
* Authenticate a request and return the actor
* Called by smartregistry for every incoming request
* Authenticate with username/password credentials
* Returns userId on success, null on failure
*/
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);
public async authenticate(
credentials: plugins.smartregistry.ICredentials
): Promise<string | null> {
const result = await this.authService.login(credentials.username, credentials.password);
if (!result.success || !result.user) return null;
return result.user.id;
}
/**
* Check if actor has permission for the requested action
* Validate a token and return auth token info
*/
public async validateToken(
token: string,
protocol?: plugins.smartregistry.TRegistryProtocol
): Promise<plugins.smartregistry.IAuthToken | null> {
// Try API token (srg_ prefix)
if (token.startsWith('srg_')) {
const result = await this.tokenService.validateToken(token);
if (!result.valid || !result.token || !result.user) return null;
return {
type: (protocol || result.token.protocols[0] || 'npm') as plugins.smartregistry.TRegistryProtocol,
userId: result.user.id,
scopes: result.token.scopes.map((s) =>
`${s.protocol}:${s.actions.join(',')}`
),
readonly: !result.token.scopes.some((s) =>
s.actions.includes('write') || s.actions.includes('*')
),
};
}
// Try JWT access token
const validated = await this.authService.validateAccessToken(token);
if (!validated) return null;
return {
type: (protocol || 'npm') as plugins.smartregistry.TRegistryProtocol,
userId: validated.user.id,
scopes: ['*'],
};
}
/**
* Create a new token for a user and protocol
*/
public async createToken(
userId: string,
protocol: plugins.smartregistry.TRegistryProtocol,
options?: plugins.smartregistry.ITokenOptions
): Promise<string> {
const result = await this.tokenService.createToken({
userId,
name: `${protocol}-token`,
protocols: [protocol as TRegistryProtocol],
scopes: [
{
protocol: protocol as TRegistryProtocol,
actions: options?.readonly ? ['read'] : ['read', 'write', 'delete'],
},
],
});
return result.rawToken;
}
/**
* Revoke a token
*/
public async revokeToken(token: string): Promise<void> {
if (token.startsWith('srg_')) {
// Hash and find the token
const result = await this.tokenService.validateToken(token);
if (result.valid && result.token) {
await this.tokenService.revokeToken(result.token.id, 'provider_revoked');
}
}
}
/**
* Check if a token holder is authorized for a resource and action
*/
public async authorize(
actor: plugins.smartregistry.IRequestActor,
request: plugins.smartregistry.IAuthorizationRequest
): Promise<plugins.smartregistry.IAuthorizationResult> {
const stackActor = actor as IStackGalleryActor;
token: plugins.smartregistry.IAuthToken | null,
resource: string,
action: string
): Promise<boolean> {
// Anonymous access: only public reads
if (!token) return false;
// 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,
};
}
// Parse resource string (format: "protocol:type:name" or "org/repo")
const userId = token.userId;
if (!userId) return false;
// 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
const mappedAction = this.mapAction(action);
// Map action to TAction
const action = this.mapAction(request.action);
// For simple authorization without specific resource context,
// check if user is active
const user = await User.findById(userId);
if (!user || !user.isActive) return false;
// Resolve permissions
const permissions = await this.permissionService.resolvePermissions({
userId: stackActor.userId!,
organizationId: request.organizationId,
repositoryId: request.repositoryId,
protocol: request.protocol as TRegistryProtocol,
});
// System admins bypass all checks
if (user.isSystemAdmin) return true;
// 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,
},
};
return mappedAction === 'read'; // Default: authenticated users can read
}
/**

View File

@@ -6,12 +6,12 @@
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 {
export interface IStorageProviderConfig {
bucket: plugins.smartbucket.SmartBucket;
bucketName: string;
basePath: string;
}
@@ -20,222 +20,192 @@ export interface IStorageConfig {
* and stores artifacts in S3 via smartbucket
*/
export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageHooks {
private config: IStorageConfig;
private config: IStorageProviderConfig;
constructor(config: IStorageConfig) {
constructor(config: IStorageProviderConfig) {
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> {
public async beforePut(
context: plugins.smartregistry.IStorageHookContext
): Promise<plugins.smartregistry.IBeforePutResult> {
// Validate organization exists and has quota
const org = await Organization.findById(context.organizationId);
if (!org) {
throw new Error(`Organization not found: ${context.organizationId}`);
}
const orgId = context.actor?.orgId;
if (orgId) {
const org = await Organization.findById(orgId);
if (!org) {
return { allowed: false, reason: `Organization not found: ${orgId}` };
}
// 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');
// Check storage quota
const newSize = context.metadata?.size || 0;
if (!org.hasStorageAvailable(newSize)) {
return { allowed: false, reason: '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;
return { allowed: true };
}
/**
* Called after a package is successfully stored
* Update database records and metrics
*/
public async afterStore(context: plugins.smartregistry.IStorageContext): Promise<void> {
public async afterPut(
context: plugins.smartregistry.IStorageHookContext
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
const packageName = context.metadata?.packageName || context.key;
const version = context.metadata?.version || 'unknown';
const orgId = context.actor?.orgId || '';
const packageId = Package.generateId(protocol, orgId, 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.organizationId = orgId;
pkg.protocol = protocol;
pkg.name = context.packageName;
pkg.createdById = context.actorId || '';
pkg.name = packageName;
pkg.createdById = context.actor?.userId || '';
pkg.createdAt = new Date();
}
// Add version
pkg.addVersion({
version: context.version,
version,
publishedAt: new Date(),
publishedBy: context.actorId || '',
size: context.size || 0,
checksum: context.checksum || '',
checksumAlgorithm: context.checksumAlgorithm || 'sha256',
publishedById: context.actor?.userId || '',
size: context.metadata?.size || 0,
digest: context.metadata?.digest,
downloads: 0,
metadata: context.metadata || {},
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;
pkg.distTags['latest'] = 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();
if (orgId) {
const org = await Organization.findById(orgId);
if (org) {
await org.updateStorageUsage(context.metadata?.size || 0);
}
}
// 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;
if (context.actor?.userId) {
await AuditService.withContext({
actorId: context.actor.userId,
actorType: 'user',
organizationId: orgId,
}).logPackagePublished(packageId, packageName, version, orgId, '');
}
}
/**
* Called after a package is fetched
* Update download metrics
*/
public async afterFetch(context: plugins.smartregistry.IFetchContext): Promise<void> {
public async afterGet(
context: plugins.smartregistry.IStorageHookContext
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
const packageName = context.metadata?.packageName || context.key;
const version = context.metadata?.version;
const orgId = context.actor?.orgId || '';
const packageId = Package.generateId(protocol, orgId, 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
);
await pkg.incrementDownloads(version);
}
}
/**
* Called before a package is deleted
*/
public async beforeDelete(context: plugins.smartregistry.IDeleteContext): Promise<plugins.smartregistry.IDeleteContext> {
return context;
public async beforeDelete(
context: plugins.smartregistry.IStorageHookContext
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
return { allowed: true };
}
/**
* Called after a package is deleted
*/
public async afterDelete(context: plugins.smartregistry.IDeleteContext): Promise<void> {
public async afterDelete(
context: plugins.smartregistry.IStorageHookContext
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
const packageName = context.metadata?.packageName || context.key;
const version = context.metadata?.version;
const orgId = context.actor?.orgId || '';
const packageId = Package.generateId(protocol, orgId, 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];
if (version) {
const versionData = pkg.versions[version];
if (versionData) {
const sizeReduction = versionData.size;
delete pkg.versions[version];
pkg.storageBytes -= sizeReduction;
// Update dist tags
for (const [tag, ver] of Object.entries(pkg.distTags)) {
if (ver === context.version) {
if (ver === 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();
if (orgId) {
const org = await Organization.findById(orgId);
if (org) {
await org.updateStorageUsage(-sizeReduction);
}
}
}
} 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();
if (orgId) {
const org = await Organization.findById(orgId);
if (org) {
await org.updateStorageUsage(-sizeReduction);
}
}
}
// 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,
});
if (context.actor?.userId) {
await AuditService.withContext({
actorId: context.actor.userId,
actorType: 'user',
organizationId: orgId,
}).log('PACKAGE_DELETED', 'package', {
resourceId: packageId,
resourceName: packageName,
metadata: { version },
success: true,
});
}
}
/**
@@ -259,11 +229,10 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
data: Uint8Array,
contentType?: string
): Promise<string> {
const bucket = await this.config.bucket.getBucket();
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
await bucket.fastPut({
path,
contents: Buffer.from(data),
contentType: contentType || 'application/octet-stream',
contents: data as unknown as string,
});
return path;
}
@@ -273,10 +242,10 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
*/
public async fetchArtifact(path: string): Promise<Uint8Array | null> {
try {
const bucket = await this.config.bucket.getBucket();
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
const file = await bucket.fastGet({ path });
if (!file) return null;
return new Uint8Array(file.contents);
return new Uint8Array(file);
} catch {
return null;
}
@@ -287,8 +256,8 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
*/
public async deleteArtifact(path: string): Promise<boolean> {
try {
const bucket = await this.config.bucket.getBucket();
await bucket.fastDelete({ path });
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
await bucket.fastRemove({ path });
return true;
} catch {
return false;

View File

@@ -86,6 +86,7 @@ export class StackGalleryRegistry {
// Initialize storage hooks
this.storageHooks = new StackGalleryStorageHooks({
bucket: this.smartBucket,
bucketName: this.config.s3Bucket,
basePath: this.config.storagePath!,
});
@@ -95,16 +96,22 @@ export class StackGalleryRegistry {
authProvider: this.authProvider,
storageHooks: this.storageHooks,
storage: {
type: 's3',
bucket: this.smartBucket,
basePath: this.config.storagePath,
endpoint: this.config.s3Endpoint,
accessKey: this.config.s3AccessKey,
accessSecret: this.config.s3SecretKey,
bucketName: this.config.s3Bucket,
region: this.config.s3Region,
},
auth: {
jwtSecret: this.config.jwtSecret || 'change-me-in-production',
tokenStore: 'database',
npmTokens: { enabled: true },
ociTokens: {
enabled: true,
realm: 'stack.gallery',
service: 'registry',
},
},
upstreamCache: this.config.enableUpstreamCache
? {
enabled: true,
expiryHours: this.config.upstreamCacheExpiry,
}
: undefined,
});
console.log('[StackGalleryRegistry] smartregistry initialized');
@@ -161,30 +168,34 @@ export class StackGalleryRegistry {
}
// Registry protocol endpoints (handled by smartregistry)
// NPM: /-/..., /@scope/package (but not /packages which is UI route)
// OCI: /v2/...
// Maven: /maven2/...
// PyPI: /simple/..., /pypi/...
// Cargo: /api/v1/crates/...
// Composer: /packages.json, /p/...
// RubyGems: /api/v1/gems/..., /gems/...
const registryPaths = ['/-/', '/v2/', '/maven2/', '/simple/', '/pypi/', '/api/v1/crates/', '/packages.json', '/p/', '/api/v1/gems/', '/gems/'];
const isRegistryPath = registryPaths.some(p => path.startsWith(p)) ||
(path.startsWith('/@') && !path.startsWith('/@stack'));
const registryPaths = [
'/-/',
'/v2/',
'/maven2/',
'/simple/',
'/pypi/',
'/api/v1/crates/',
'/packages.json',
'/p/',
'/api/v1/gems/',
'/gems/',
];
const isRegistryPath =
registryPaths.some((p) => path.startsWith(p)) ||
(path.startsWith('/@') && !path.startsWith('/@stack'));
if (this.smartRegistry && isRegistryPath) {
try {
const response = await this.smartRegistry.handleRequest(request);
if (response) return response;
// Convert Request to IRequestContext
const requestContext = await this.requestToContext(request);
const response = await this.smartRegistry.handleRequest(requestContext);
if (response) return this.contextResponseToResponse(response);
} 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(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
@@ -197,6 +208,82 @@ export class StackGalleryRegistry {
return this.serveStaticFile(path);
}
/**
* Convert a Deno Request to smartregistry IRequestContext
*/
private async requestToContext(
request: Request
): Promise<plugins.smartregistry.IRequestContext> {
const url = new URL(request.url);
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
let body: unknown = undefined;
// deno-lint-ignore no-explicit-any
let rawBody: any = undefined;
if (request.body && request.method !== 'GET' && request.method !== 'HEAD') {
try {
const bytes = new Uint8Array(await request.arrayBuffer());
rawBody = bytes;
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('json')) {
body = JSON.parse(new TextDecoder().decode(bytes));
}
} catch {
// Body parsing failed, continue with undefined body
}
}
// Extract token from Authorization header
let token: string | undefined;
const authHeader = headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
}
return {
method: request.method,
path: url.pathname,
headers,
query,
body,
rawBody,
token,
};
}
/**
* Convert smartregistry IResponse to Deno Response
*/
private contextResponseToResponse(response: plugins.smartregistry.IResponse): Response {
const headers = new Headers(response.headers || {});
let body: BodyInit | null = null;
if (response.body !== undefined) {
if (typeof response.body === 'string') {
body = response.body;
} else if (response.body instanceof Uint8Array) {
body = response.body as unknown as BodyInit;
} else {
body = JSON.stringify(response.body);
if (!headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
}
}
return new Response(body, {
status: response.status,
headers,
});
}
/**
* Serve static files from embedded UI
*/
@@ -206,7 +293,7 @@ export class StackGalleryRegistry {
// Get embedded file
const embeddedFile = getEmbeddedFile(filePath);
if (embeddedFile) {
return new Response(embeddedFile.data, {
return new Response(embeddedFile.data as unknown as BodyInit, {
status: 200,
headers: { 'Content-Type': embeddedFile.contentType },
});
@@ -215,7 +302,7 @@ export class StackGalleryRegistry {
// SPA fallback: serve index.html for unknown paths
const indexFile = getEmbeddedFile('/index.html');
if (indexFile) {
return new Response(indexFile.data, {
return new Response(indexFile.data as unknown as BodyInit, {
status: 200,
headers: { 'Content-Type': 'text/html' },
});
@@ -229,13 +316,10 @@ export class StackGalleryRegistry {
*/
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 new Response(JSON.stringify({ error: 'API router not initialized' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
return await this.apiRouter.handle(request);
@@ -336,7 +420,9 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
const s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`;
const config: IRegistryConfig = {
mongoUrl: env.MONGODB_URL || `mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
mongoUrl:
env.MONGODB_URL ||
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
mongoDb: env.MONGODB_NAME || 'stackgallery',
s3Endpoint: s3Endpoint,
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
@@ -356,7 +442,10 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
if (error instanceof Deno.errors.NotFound) {
console.log('[StackGalleryRegistry] No .nogit/env.json found, using environment variables');
} else {
console.warn('[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:', error);
console.warn(
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
error
);
}
return createRegistryFromEnv();
}

View File

@@ -50,9 +50,9 @@ export class CryptoService {
// Encrypt
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
this.masterKey,
encoded
encoded.buffer as ArrayBuffer
);
// Format: iv:ciphertext (both base64)
@@ -86,9 +86,9 @@ export class CryptoService {
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
this.masterKey,
encrypted
encrypted.buffer as ArrayBuffer
);
// Decode to string
@@ -120,7 +120,7 @@ export class CryptoService {
const keyBytes = this.hexToBytes(keyHex);
return await crypto.subtle.importKey(
'raw',
keyBytes,
keyBytes.buffer as ArrayBuffer,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']

View File

@@ -103,7 +103,7 @@ export class ExternalAuthService {
try {
externalUser = await strategy.handleCallback(data);
} catch (error) {
await this.auditService.log('USER_LOGIN', 'user', {
await this.auditService.log('AUTH_LOGIN', 'user', {
success: false,
metadata: {
providerId: provider.id,
@@ -143,7 +143,7 @@ export class ExternalAuthService {
actorType: 'user',
actorIp: options.ipAddress,
actorUserAgent: options.userAgent,
}).log('USER_LOGIN', 'user', {
}).log('AUTH_LOGIN', 'user', {
resourceId: user.id,
success: true,
metadata: {
@@ -194,7 +194,7 @@ export class ExternalAuthService {
try {
externalUser = await strategy.authenticateCredentials(username, password);
} catch (error) {
await this.auditService.log('USER_LOGIN', 'user', {
await this.auditService.log('AUTH_LOGIN', 'user', {
success: false,
metadata: {
providerId: provider.id,
@@ -235,7 +235,7 @@ export class ExternalAuthService {
actorType: 'user',
actorIp: options.ipAddress,
actorUserAgent: options.userAgent,
}).log('USER_LOGIN', 'user', {
}).log('AUTH_LOGIN', 'user', {
resourceId: user.id,
success: true,
metadata: {