fix(registry): restore protocol routing and test coverage for npm, oci, and api flows
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.8.4',
|
||||
version: '1.8.5',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
|
||||
@@ -144,15 +144,30 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
||||
// Map action
|
||||
const mappedAction = this.mapAction(action);
|
||||
|
||||
// For simple authorization without specific resource context,
|
||||
// check if user is active
|
||||
// Check if user is active
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return false;
|
||||
|
||||
// System admins bypass all checks
|
||||
if (user.isSystemAdmin) return true;
|
||||
|
||||
return mappedAction === 'read'; // Default: authenticated users can read
|
||||
// Check token scopes for the requested action
|
||||
if (token.scopes) {
|
||||
for (const scope of token.scopes) {
|
||||
// Scope format: "protocol:action1,action2" or "*"
|
||||
if (scope === '*') return true;
|
||||
const [, actions] = scope.split(':');
|
||||
if (actions) {
|
||||
const actionList = actions.split(',');
|
||||
if (actionList.includes(mappedAction) || actionList.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: authenticated users can read
|
||||
return mappedAction === 'read';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
119
ts/registry.ts
119
ts/registry.ts
@@ -8,6 +8,7 @@ import { closeDb, initDb, isDbConnected } from './models/db.ts';
|
||||
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
||||
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
||||
import { OpsServer } from './opsserver/classes.opsserver.ts';
|
||||
import { ApiRouter } from './api/router.ts';
|
||||
|
||||
// Bundled UI files (generated by tsbundle with base64ts output mode)
|
||||
let bundledFileMap: Map<string, { data: Uint8Array; contentType: string }> | null = null;
|
||||
@@ -77,6 +78,7 @@ export class StackGalleryRegistry {
|
||||
private authProvider: StackGalleryAuthProvider | null = null;
|
||||
private storageHooks: StackGalleryStorageHooks | null = null;
|
||||
private opsServer: OpsServer | null = null;
|
||||
private apiRouter: ApiRouter | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: IRegistryConfig) {
|
||||
@@ -141,13 +143,21 @@ export class StackGalleryRegistry {
|
||||
npmTokens: { enabled: true },
|
||||
ociTokens: {
|
||||
enabled: true,
|
||||
realm: 'stack.gallery',
|
||||
realm: `http://${this.config.host === '0.0.0.0' ? 'localhost' : this.config.host}:${this.config.port}/v2/token`,
|
||||
service: 'registry',
|
||||
},
|
||||
},
|
||||
npm: { enabled: true, basePath: '/-/npm' },
|
||||
oci: { enabled: true, basePath: '/v2' },
|
||||
});
|
||||
await this.smartRegistry.init();
|
||||
console.log('[StackGalleryRegistry] smartregistry initialized');
|
||||
|
||||
// Initialize REST API router
|
||||
console.log('[StackGalleryRegistry] Initializing API router...');
|
||||
this.apiRouter = new ApiRouter();
|
||||
console.log('[StackGalleryRegistry] API router initialized');
|
||||
|
||||
// Initialize OpsServer (TypedRequest handlers)
|
||||
console.log('[StackGalleryRegistry] Initializing OpsServer...');
|
||||
this.opsServer = new OpsServer(this);
|
||||
@@ -198,31 +208,40 @@ export class StackGalleryRegistry {
|
||||
return await this.handleTypedRequest(request);
|
||||
}
|
||||
|
||||
// Legacy REST API endpoints (keep for backwards compatibility during migration)
|
||||
// TODO: Remove once frontend is fully migrated to TypedRequest
|
||||
|
||||
// Registry protocol endpoints (handled by smartregistry)
|
||||
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) {
|
||||
// NPM: /-/npm/{orgName}/... -> strip orgName, forward as /-/npm/...
|
||||
// OCI: /v2/{orgName}/... -> forward as /v2/{orgName}/... (OCI uses name segments natively)
|
||||
if (this.smartRegistry) {
|
||||
try {
|
||||
// Convert Request to IRequestContext
|
||||
const requestContext = await this.requestToContext(request);
|
||||
const response = await this.smartRegistry.handleRequest(requestContext);
|
||||
if (response) return this.contextResponseToResponse(response);
|
||||
// NPM protocol: extract org from /-/npm/{orgName}/...
|
||||
if (path.startsWith('/-/npm/')) {
|
||||
const orgMatch = path.match(/^\/-\/npm\/([^\/]+)(\/.*)?$/);
|
||||
if (orgMatch) {
|
||||
const orgName = decodeURIComponent(orgMatch[1]);
|
||||
const remainder = orgMatch[2] || '/';
|
||||
const requestContext = await this.requestToContext(request);
|
||||
requestContext.path = `/-/npm${remainder}`;
|
||||
if (!requestContext.actor) {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
requestContext.actor = {} as any;
|
||||
}
|
||||
requestContext.actor!.orgId = orgName;
|
||||
const response = await this.smartRegistry.handleRequest(requestContext);
|
||||
if (response) return this.contextResponseToResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
// OCI token endpoint: /v2/token (Docker Bearer auth flow)
|
||||
if (path === '/v2/token') {
|
||||
return this.handleOciTokenRequest(request);
|
||||
}
|
||||
|
||||
// OCI protocol: /v2/... or /v2
|
||||
if (path.startsWith('/v2/') || path === '/v2') {
|
||||
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' }), {
|
||||
@@ -232,6 +251,11 @@ export class StackGalleryRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// REST API endpoints
|
||||
if (this.apiRouter && path.startsWith('/api/')) {
|
||||
return this.apiRouter.handle(request);
|
||||
}
|
||||
|
||||
// Serve static UI files
|
||||
return this.serveStaticFile(path);
|
||||
}
|
||||
@@ -374,6 +398,53 @@ export class StackGalleryRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OCI token requests (Docker Bearer auth flow)
|
||||
* Docker sends GET /v2/token?service=...&scope=... to obtain a Bearer token
|
||||
*/
|
||||
private async handleOciTokenRequest(request: Request): Promise<Response> {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
let apiToken: string | undefined;
|
||||
|
||||
// Extract token from Basic auth (Docker sends username:password)
|
||||
if (authHeader?.startsWith('Basic ')) {
|
||||
const credentials = atob(authHeader.substring(6));
|
||||
const [_username, password] = credentials.split(':');
|
||||
if (password) {
|
||||
apiToken = password;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token from Bearer auth
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
apiToken = authHeader.substring(7);
|
||||
}
|
||||
|
||||
if (apiToken) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token: apiToken,
|
||||
access_token: apiToken,
|
||||
expires_in: 3600,
|
||||
issued_at: new Date().toISOString(),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// No auth provided — return 401
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'authentication required' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user