fix(registry): align registry integrations with updated auth, storage, repository, and audit models
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-20 - 1.4.2 - fix(registry)
|
||||
align registry integrations with updated auth, storage, repository, and audit models
|
||||
|
||||
- update smartregistry auth and storage provider implementations to match the current request, token, and storage hook APIs
|
||||
- fix audit events for auth provider, platform settings, and external authentication flows to use dedicated event types
|
||||
- adapt repository, organization, user, and package handlers to renamed model fields and revised repository visibility/protocol data
|
||||
- add missing repository and team model fields plus helper methods needed by the updated API and permission flows
|
||||
- correct AES-GCM crypto buffer handling and package version checksum mapping
|
||||
|
||||
## 2026-03-20 - 1.4.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.5",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@push.rocks/smartcli": "npm:@push.rocks/smartcli@^4.0.20",
|
||||
"@push.rocks/smartarchive": "npm:@push.rocks/smartarchive@^5.2.1",
|
||||
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.3",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.0",
|
||||
"@std/path": "jsr:@std/path@^1.0.0",
|
||||
|
||||
86
deno.lock
generated
86
deno.lock
generated
@@ -20,7 +20,6 @@
|
||||
"jsr:@std/testing@*": "1.0.17",
|
||||
"npm:@git.zone/tsdeno@^1.2.0": "1.2.0",
|
||||
"npm:@push.rocks/qenv@^6.1.3": "6.1.3",
|
||||
"npm:@push.rocks/smartarchive@^5.2.1": "5.2.1",
|
||||
"npm:@push.rocks/smartbucket@^4.5.1": "4.5.1",
|
||||
"npm:@push.rocks/smartcli@^4.0.20": "4.0.20",
|
||||
"npm:@push.rocks/smartcrypto@^2.0.4": "2.0.4",
|
||||
@@ -805,7 +804,7 @@
|
||||
"integrity": "sha512-kW0ZUGyf1e4nwloVwBQjNId+MzgTcNS834C+RxH21i1NqyOubbpWZtJtPP+K+s35nSJRyCTy3ICfBMdDBTAm2w==",
|
||||
"dependencies": [
|
||||
"@push.rocks/lik",
|
||||
"@push.rocks/smartfile@11.2.7",
|
||||
"@push.rocks/smartfile",
|
||||
"@push.rocks/smartjson@5.2.0",
|
||||
"@push.rocks/smartpath@6.0.0",
|
||||
"@push.rocks/smartpromise",
|
||||
@@ -818,7 +817,7 @@
|
||||
"integrity": "sha512-snLpSHwaQ5OXlZzF1KX/FY71W5LwajjBzor82Vue0smjEPnSeUPY5/JcVdMwtdprdJe13pc/EQQuIiL/zw4/yg==",
|
||||
"dependencies": [
|
||||
"@push.rocks/qenv",
|
||||
"@push.rocks/smartfile@11.2.7",
|
||||
"@push.rocks/smartfile",
|
||||
"@push.rocks/smartjson@5.2.0",
|
||||
"@push.rocks/smartlog",
|
||||
"@push.rocks/smartpath@6.0.0",
|
||||
@@ -834,31 +833,12 @@
|
||||
"dependencies": [
|
||||
"@api.global/typedrequest",
|
||||
"@configvault.io/interfaces",
|
||||
"@push.rocks/smartfile@11.2.7",
|
||||
"@push.rocks/smartfile",
|
||||
"@push.rocks/smartlog",
|
||||
"@push.rocks/smartpath@6.0.0"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/qenv/-/qenv-6.1.3.tgz"
|
||||
},
|
||||
"@push.rocks/smartarchive@5.2.1": {
|
||||
"integrity": "sha512-TNv5q6QuBRX7jrzffiyb6A8AALNAr0kyAcJswa0l3ahBP1Q6zszNo9xOVXmW2gKX2KShtO/Y+Cn0i46n8lbnaQ==",
|
||||
"dependencies": [
|
||||
"@push.rocks/smartdelay",
|
||||
"@push.rocks/smartfile@13.1.2",
|
||||
"@push.rocks/smartpath@6.0.0",
|
||||
"@push.rocks/smartpromise",
|
||||
"@push.rocks/smartrequest@5.0.1",
|
||||
"@push.rocks/smartrx",
|
||||
"@push.rocks/smartstream",
|
||||
"@push.rocks/smartunique",
|
||||
"@push.rocks/smarturl",
|
||||
"fflate",
|
||||
"file-type@21.3.3",
|
||||
"modern-tar",
|
||||
"tar-stream"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartarchive/-/smartarchive-5.2.1.tgz"
|
||||
},
|
||||
"@push.rocks/smartbucket@4.5.1": {
|
||||
"integrity": "sha512-mce9x7YH68ZgNLJU0ZWflt03AlS+jMe9BNZNhwM0N5T87q1uhNFvjFzkvyhBj8XO6g4CTQvQGxPuJXZqD5aUsg==",
|
||||
"dependencies": [
|
||||
@@ -1005,26 +985,6 @@
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartfile/-/smartfile-11.2.7.tgz"
|
||||
},
|
||||
"@push.rocks/smartfile@13.1.2": {
|
||||
"integrity": "sha512-DaEhwmnGEpX4coeeToaw4cZe3pNBhH7CY1iGr+d3pIXihozREvzzAR9/0i2r7bUXXL5+Lgy8YYIk5ZS+fwxMKA==",
|
||||
"dependencies": [
|
||||
"@push.rocks/lik",
|
||||
"@push.rocks/smartdelay",
|
||||
"@push.rocks/smartfile-interfaces",
|
||||
"@push.rocks/smartfs",
|
||||
"@push.rocks/smarthash",
|
||||
"@push.rocks/smartjson@5.2.0",
|
||||
"@push.rocks/smartmime",
|
||||
"@push.rocks/smartpath@6.0.0",
|
||||
"@push.rocks/smartpromise",
|
||||
"@push.rocks/smartrequest@4.4.2",
|
||||
"@push.rocks/smartstream",
|
||||
"@types/js-yaml@4.0.9",
|
||||
"glob",
|
||||
"js-yaml@4.1.1"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartfile/-/smartfile-13.1.2.tgz"
|
||||
},
|
||||
"@push.rocks/smartfs@1.5.0": {
|
||||
"integrity": "sha512-QwMD44HgX3d9PPxUwR0uS+0PEMtesKvKbZR+s4pezL2er6oPneKJMLkO6TJPvJ38nug6Lmlk9Bu7UrwR2kS3Vw==",
|
||||
"dependencies": [
|
||||
@@ -1091,7 +1051,7 @@
|
||||
"@push.rocks/consolecolor",
|
||||
"@push.rocks/isounique",
|
||||
"@push.rocks/smartclickhouse",
|
||||
"@push.rocks/smartfile@11.2.7",
|
||||
"@push.rocks/smartfile",
|
||||
"@push.rocks/smarthash",
|
||||
"@push.rocks/smartpromise",
|
||||
"@push.rocks/smarttime",
|
||||
@@ -1127,7 +1087,7 @@
|
||||
"integrity": "sha512-mG6lRBLr5nF+GLZmgCcdjhdDsmTtJWBFZDCa1eJ8Au9TvUzbPW0fY5aqJBb3UwfyZzH6St8Th9cJSXjagOQkYA==",
|
||||
"dependencies": [
|
||||
"@types/mime-types",
|
||||
"file-type@19.6.0",
|
||||
"file-type",
|
||||
"mime"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartmime/-/smartmime-2.0.4.tgz"
|
||||
@@ -1894,14 +1854,6 @@
|
||||
"integrity": "sha512-ypeB0FuHLHOCQXW4d0RQ69txPJJH+1CHcpsZIUdcv2t1vR0IVyQr2vHihtde9UOXhjzqEnUphWon/UcJNsa0YA==",
|
||||
"tarball": "https://verdaccio.lossless.digital/@tempfix/lenis/-/lenis-1.3.20.tgz"
|
||||
},
|
||||
"@tokenizer/inflate@0.4.1": {
|
||||
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
|
||||
"dependencies": [
|
||||
"debug",
|
||||
"token-types"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@tokenizer/inflate/-/inflate-0.4.1.tgz"
|
||||
},
|
||||
"@tokenizer/token@0.3.0": {
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"tarball": "https://verdaccio.lossless.digital/@tokenizer/token/-/token-0.3.0.tgz"
|
||||
@@ -2483,30 +2435,16 @@
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/fault/-/fault-2.0.1.tgz"
|
||||
},
|
||||
"fflate@0.8.2": {
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"tarball": "https://verdaccio.lossless.digital/fflate/-/fflate-0.8.2.tgz"
|
||||
},
|
||||
"file-type@19.6.0": {
|
||||
"integrity": "sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==",
|
||||
"dependencies": [
|
||||
"get-stream",
|
||||
"strtok3@9.1.1",
|
||||
"strtok3",
|
||||
"token-types",
|
||||
"uint8array-extras"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/file-type/-/file-type-19.6.0.tgz"
|
||||
},
|
||||
"file-type@21.3.3": {
|
||||
"integrity": "sha512-pNwbwz8c3aZ+GvbJnIsCnDjKvgCZLHxkFWLEFxU3RMa+Ey++ZSEfisvsWQMcdys6PpxQjWUOIDi1fifXsW3YRg==",
|
||||
"dependencies": [
|
||||
"@tokenizer/inflate",
|
||||
"strtok3@10.3.4",
|
||||
"token-types",
|
||||
"uint8array-extras"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/file-type/-/file-type-21.3.3.tgz"
|
||||
},
|
||||
"find-cache-dir@3.3.2": {
|
||||
"integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
|
||||
"dependencies": [
|
||||
@@ -3370,10 +3308,6 @@
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"tarball": "https://verdaccio.lossless.digital/minipass/-/minipass-7.1.3.tgz"
|
||||
},
|
||||
"modern-tar@0.7.5": {
|
||||
"integrity": "sha512-YTefgdpKKFgoTDbEUqXqgUJct2OG6/4hs4XWLsxcHkDLj/x/V8WmKIRppPnXP5feQ7d1vuYWSp3qKkxfwaFaxA==",
|
||||
"tarball": "https://verdaccio.lossless.digital/modern-tar/-/modern-tar-0.7.5.tgz"
|
||||
},
|
||||
"mongodb-connection-string-url@3.0.2": {
|
||||
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
|
||||
"dependencies": [
|
||||
@@ -3738,13 +3672,6 @@
|
||||
"integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==",
|
||||
"tarball": "https://verdaccio.lossless.digital/strnum/-/strnum-2.2.1.tgz"
|
||||
},
|
||||
"strtok3@10.3.4": {
|
||||
"integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
|
||||
"dependencies": [
|
||||
"@tokenizer/token"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/strtok3/-/strtok3-10.3.4.tgz"
|
||||
},
|
||||
"strtok3@9.1.1": {
|
||||
"integrity": "sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==",
|
||||
"dependencies": [
|
||||
@@ -4038,7 +3965,6 @@
|
||||
"jsr:@std/http@1",
|
||||
"jsr:@std/path@1",
|
||||
"npm:@push.rocks/qenv@^6.1.3",
|
||||
"npm:@push.rocks/smartarchive@^5.2.1",
|
||||
"npm:@push.rocks/smartbucket@^4.5.1",
|
||||
"npm:@push.rocks/smartcli@^4.0.20",
|
||||
"npm:@push.rocks/smartcrypto@^2.0.4",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -174,7 +174,7 @@ export class PackageApi {
|
||||
publishedAt: data.publishedAt,
|
||||
size: data.size,
|
||||
downloads: data.downloads,
|
||||
checksum: data.checksum,
|
||||
checksum: data.metadata?.checksum,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,223 +20,193 @@ 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);
|
||||
const orgId = context.actor?.orgId;
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (!org) {
|
||||
throw new Error(`Organization not found: ${context.organizationId}`);
|
||||
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');
|
||||
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 (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
org.usedStorageBytes += context.size || 0;
|
||||
await org.save();
|
||||
await org.updateStorageUsage(context.metadata?.size || 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
if (context.actor?.userId) {
|
||||
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
|
||||
);
|
||||
actorId: context.actor.userId,
|
||||
actorType: 'user',
|
||||
organizationId: orgId,
|
||||
}).logPackagePublished(packageId, packageName, version, orgId, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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];
|
||||
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 (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
org.usedStorageBytes -= sizeReduction;
|
||||
await org.save();
|
||||
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 (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
org.usedStorageBytes -= sizeReduction;
|
||||
await org.save();
|
||||
await org.updateStorageUsage(-sizeReduction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
if (context.actor?.userId) {
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: context.actorId ? 'user' : 'system',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
actorId: context.actor.userId,
|
||||
actorType: 'user',
|
||||
organizationId: orgId,
|
||||
}).log('PACKAGE_DELETED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: context.packageName,
|
||||
metadata: { version: context.version },
|
||||
resourceName: packageName,
|
||||
metadata: { version },
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the S3 path for a package artifact
|
||||
@@ -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;
|
||||
|
||||
155
ts/registry.ts
155
ts/registry.ts
@@ -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,
|
||||
},
|
||||
upstreamCache: this.config.enableUpstreamCache
|
||||
? {
|
||||
auth: {
|
||||
jwtSecret: this.config.jwtSecret || 'change-me-in-production',
|
||||
tokenStore: 'database',
|
||||
npmTokens: { enabled: true },
|
||||
ociTokens: {
|
||||
enabled: true,
|
||||
expiryHours: this.config.upstreamCacheExpiry,
|
||||
}
|
||||
: undefined,
|
||||
realm: 'stack.gallery',
|
||||
service: 'registry',
|
||||
},
|
||||
},
|
||||
});
|
||||
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)) ||
|
||||
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' }),
|
||||
{
|
||||
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' }),
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user