fix(registry): restore protocol routing and test coverage for npm, oci, and api flows

This commit is contained in:
2026-03-22 08:59:34 +00:00
parent 2d84470688
commit 3b2aa57b7d
14 changed files with 312 additions and 109 deletions

View File

@@ -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
*/