Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67188a4e9f | |||
| a2f7f43027 | |||
| 37a89239d9 | |||
| 93fee289e7 | |||
| 30fd9a4238 | |||
| 3b5bf5e789 |
22
changelog.md
22
changelog.md
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-25 - 2.1.2 - fix(oci)
|
||||||
|
Prefer raw request body for content-addressable OCI operations and expose rawBody on request context
|
||||||
|
|
||||||
|
- Add rawBody?: Buffer to IRequestContext to allow callers to provide the exact raw request bytes for digest calculation (falls back to body if absent).
|
||||||
|
- OCI registry handlers now prefer context.rawBody over context.body for content-addressable operations (manifests, blobs, and blob uploads) to preserve exact bytes and ensure digest calculation matches client expectations.
|
||||||
|
- Upload flow updates: upload init, PATCH (upload chunk) and PUT (complete upload) now pass rawBody when available.
|
||||||
|
|
||||||
|
## 2025-11-25 - 2.1.1 - fix(oci)
|
||||||
|
Preserve raw manifest bytes for digest calculation and handle string/JSON manifest bodies in OCI registry
|
||||||
|
|
||||||
|
- Preserve the exact bytes of the manifest payload when computing the sha256 digest to comply with the OCI spec and avoid mismatches caused by re-serialization.
|
||||||
|
- Accept string request bodies (converted using UTF-8) and treat already-parsed JSON objects by re-serializing as a fallback.
|
||||||
|
- Keep existing content-type fallback logic while ensuring accurate digest calculation prior to storing manifests.
|
||||||
|
|
||||||
|
## 2025-11-25 - 2.1.0 - feat(oci)
|
||||||
|
Support configurable OCI token realm/service and centralize unauthorized responses
|
||||||
|
|
||||||
|
- SmartRegistry now forwards optional ociTokens (realm and service) from auth configuration to OciRegistry when OCI is enabled
|
||||||
|
- OciRegistry constructor accepts an optional ociTokens parameter and stores it for use in auth headers
|
||||||
|
- Replaced repeated construction of WWW-Authenticate headers with createUnauthorizedResponse and createUnauthorizedHeadResponse helpers that use configured realm/service
|
||||||
|
- Behavior is backwards-compatible: when ociTokens are not configured the registry falls back to the previous defaults (realm: <basePath>/v2/token, service: "registry")
|
||||||
|
|
||||||
## 2025-11-25 - 2.0.0 - BREAKING CHANGE(pypi,rubygems)
|
## 2025-11-25 - 2.0.0 - BREAKING CHANGE(pypi,rubygems)
|
||||||
Revise PyPI and RubyGems handling: normalize error payloads, fix .gem parsing/packing, adjust PyPI JSON API and tests, and export smartarchive plugin
|
Revise PyPI and RubyGems handling: normalize error payloads, fix .gem parsing/packing, adjust PyPI JSON API and tests, and export smartarchive plugin
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartregistry",
|
"name": "@push.rocks/smartregistry",
|
||||||
"version": "2.0.0",
|
"version": "2.1.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries",
|
"description": "A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartregistry',
|
name: '@push.rocks/smartregistry',
|
||||||
version: '2.0.0',
|
version: '2.1.2',
|
||||||
description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries'
|
description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ export class SmartRegistry {
|
|||||||
// Initialize OCI registry if enabled
|
// Initialize OCI registry if enabled
|
||||||
if (this.config.oci?.enabled) {
|
if (this.config.oci?.enabled) {
|
||||||
const ociBasePath = this.config.oci.basePath ?? '/oci';
|
const ociBasePath = this.config.oci.basePath ?? '/oci';
|
||||||
const ociRegistry = new OciRegistry(this.storage, this.authManager, ociBasePath);
|
const ociTokens = this.config.auth.ociTokens?.enabled ? {
|
||||||
|
realm: this.config.auth.ociTokens.realm,
|
||||||
|
service: this.config.auth.ociTokens.service,
|
||||||
|
} : undefined;
|
||||||
|
const ociRegistry = new OciRegistry(this.storage, this.authManager, ociBasePath, ociTokens);
|
||||||
await ociRegistry.init();
|
await ociRegistry.init();
|
||||||
this.registries.set('oci', ociRegistry);
|
this.registries.set('oci', ociRegistry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ export interface IRequestContext {
|
|||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
query: Record<string, string>;
|
query: Record<string, string>;
|
||||||
body?: any;
|
body?: any;
|
||||||
|
/**
|
||||||
|
* Raw request body as bytes. MUST be provided for content-addressable operations
|
||||||
|
* (OCI manifests, blobs) to ensure digest calculation matches client expectations.
|
||||||
|
* If not provided, falls back to 'body' field.
|
||||||
|
*/
|
||||||
|
rawBody?: Buffer;
|
||||||
token?: string;
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
private uploadSessions: Map<string, IUploadSession> = new Map();
|
private uploadSessions: Map<string, IUploadSession> = new Map();
|
||||||
private basePath: string = '/oci';
|
private basePath: string = '/oci';
|
||||||
private cleanupInterval?: NodeJS.Timeout;
|
private cleanupInterval?: NodeJS.Timeout;
|
||||||
|
private ociTokens?: { realm: string; service: string };
|
||||||
|
|
||||||
constructor(storage: RegistryStorage, authManager: AuthManager, basePath: string = '/oci') {
|
constructor(
|
||||||
|
storage: RegistryStorage,
|
||||||
|
authManager: AuthManager,
|
||||||
|
basePath: string = '/oci',
|
||||||
|
ociTokens?: { realm: string; service: string }
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.authManager = authManager;
|
this.authManager = authManager;
|
||||||
this.basePath = basePath;
|
this.basePath = basePath;
|
||||||
|
this.ociTokens = ociTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init(): Promise<void> {
|
public async init(): Promise<void> {
|
||||||
@@ -55,7 +62,9 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
|
const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
|
||||||
if (manifestMatch) {
|
if (manifestMatch) {
|
||||||
const [, name, reference] = manifestMatch;
|
const [, name, reference] = manifestMatch;
|
||||||
return this.handleManifestRequest(context.method, name, reference, token, context.body, context.headers);
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
||||||
|
const bodyData = context.rawBody || context.body;
|
||||||
|
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blob operations: /v2/{name}/blobs/{digest}
|
// Blob operations: /v2/{name}/blobs/{digest}
|
||||||
@@ -69,7 +78,9 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
|
const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
|
||||||
if (uploadInitMatch && context.method === 'POST') {
|
if (uploadInitMatch && context.method === 'POST') {
|
||||||
const [, name] = uploadInitMatch;
|
const [, name] = uploadInitMatch;
|
||||||
return this.handleUploadInit(name, token, context.query, context.body);
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
||||||
|
const bodyData = context.rawBody || context.body;
|
||||||
|
return this.handleUploadInit(name, token, context.query, bodyData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blob upload operations: /v2/{name}/blobs/uploads/{uuid}
|
// Blob upload operations: /v2/{name}/blobs/uploads/{uuid}
|
||||||
@@ -254,11 +265,14 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
return this.createUnauthorizedResponse(session.repository, 'push');
|
return this.createUnauthorizedResponse(session.repository, 'push');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
||||||
|
const bodyData = context.rawBody || context.body;
|
||||||
|
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'PATCH':
|
case 'PATCH':
|
||||||
return this.uploadChunk(uploadId, context.body, context.headers['content-range']);
|
return this.uploadChunk(uploadId, bodyData, context.headers['content-range']);
|
||||||
case 'PUT':
|
case 'PUT':
|
||||||
return this.completeUpload(uploadId, context.query['digest'], context.body);
|
return this.completeUpload(uploadId, context.query['digest'], bodyData);
|
||||||
case 'GET':
|
case 'GET':
|
||||||
return this.getUploadStatus(uploadId);
|
return this.getUploadStatus(uploadId);
|
||||||
default:
|
default:
|
||||||
@@ -280,13 +294,7 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
): Promise<IResponse> {
|
): Promise<IResponse> {
|
||||||
if (!await this.checkPermission(token, repository, 'pull')) {
|
if (!await this.checkPermission(token, repository, 'pull')) {
|
||||||
return {
|
return this.createUnauthorizedResponse(repository, 'pull');
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:pull"`,
|
|
||||||
},
|
|
||||||
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve tag to digest if needed
|
// Resolve tag to digest if needed
|
||||||
@@ -367,13 +375,7 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
): Promise<IResponse> {
|
): Promise<IResponse> {
|
||||||
if (!await this.checkPermission(token, repository, 'push')) {
|
if (!await this.checkPermission(token, repository, 'push')) {
|
||||||
return {
|
return this.createUnauthorizedResponse(repository, 'push');
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:push"`,
|
|
||||||
},
|
|
||||||
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
@@ -384,7 +386,18 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifestData = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
|
// Preserve raw bytes for accurate digest calculation
|
||||||
|
// Per OCI spec, digest must match the exact bytes sent by client
|
||||||
|
let manifestData: Buffer;
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
manifestData = body;
|
||||||
|
} else if (typeof body === 'string') {
|
||||||
|
// String body - convert directly without JSON transformation
|
||||||
|
manifestData = Buffer.from(body, 'utf-8');
|
||||||
|
} else {
|
||||||
|
// Body was already parsed as JSON object - re-serialize as fallback
|
||||||
|
manifestData = Buffer.from(JSON.stringify(body));
|
||||||
|
}
|
||||||
const contentType = headers?.['content-type'] || headers?.['Content-Type'] || 'application/vnd.oci.image.manifest.v1+json';
|
const contentType = headers?.['content-type'] || headers?.['Content-Type'] || 'application/vnd.oci.image.manifest.v1+json';
|
||||||
|
|
||||||
// Calculate manifest digest
|
// Calculate manifest digest
|
||||||
@@ -685,10 +698,12 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
* Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
|
* Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
|
||||||
*/
|
*/
|
||||||
private createUnauthorizedResponse(repository: string, action: string): IResponse {
|
private createUnauthorizedResponse(repository: string, action: string): IResponse {
|
||||||
|
const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
|
||||||
|
const service = this.ociTokens?.service || 'registry';
|
||||||
return {
|
return {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: {
|
headers: {
|
||||||
'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:${action}"`,
|
'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",scope="repository:${repository}:${action}"`,
|
||||||
},
|
},
|
||||||
body: this.createError('DENIED', 'Insufficient permissions'),
|
body: this.createError('DENIED', 'Insufficient permissions'),
|
||||||
};
|
};
|
||||||
@@ -698,10 +713,12 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
* Create an unauthorized HEAD response (no body per HTTP spec).
|
* Create an unauthorized HEAD response (no body per HTTP spec).
|
||||||
*/
|
*/
|
||||||
private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
|
private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
|
||||||
|
const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
|
||||||
|
const service = this.ociTokens?.service || 'registry';
|
||||||
return {
|
return {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: {
|
headers: {
|
||||||
'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:${action}"`,
|
'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",scope="repository:${repository}:${action}"`,
|
||||||
},
|
},
|
||||||
body: null,
|
body: null,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user