diff --git a/package.json b/package.json index 684c02c..9240013 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@push.rocks/qenv": "^6.1.3", - "@push.rocks/smartbucket": "^3.3.10" + "@push.rocks/smartbucket": "^3.3.10", + "@push.rocks/smartpath": "^6.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3dfee7..08dd055 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@push.rocks/smartbucket': specifier: ^3.3.10 version: 3.3.10 + '@push.rocks/smartpath': + specifier: ^6.0.0 + version: 6.0.0 devDependencies: '@git.zone/tsbuild': specifier: ^3.1.0 diff --git a/readme.md b/readme.md index 852cfed..d635a4c 100644 --- a/readme.md +++ b/readme.md @@ -1,17 +1,39 @@ # @push.rocks/smartregistry -A TypeScript library implementing the OCI Distribution Specification v1.1 for building container and artifact registries. +A composable TypeScript library implementing both OCI Distribution Specification v1.1 and NPM Registry API for building unified container and package registries. ## Features -- **OCI Distribution Spec v1.1 Compliant**: Implements all required and optional endpoints -- **Cloud-Agnostic Storage**: Uses @push.rocks/smartbucket for S3-compatible object storage -- **Pluggable Authentication**: Async callbacks for login and authorization -- **Bearer Token Auth**: JWT-based authentication following Docker Registry Token Authentication spec -- **Programmatic API**: Use as a library in any Node.js/TypeScript application -- **Full CRUD Operations**: Push, pull, list, and delete manifests and blobs -- **Content Discovery**: Tag listing and referrers API for artifact relationships -- **Chunked Uploads**: Support for large blob uploads with resumable sessions +### Dual Protocol Support +- **OCI Distribution Spec v1.1**: Full container registry with manifest/blob operations +- **NPM Registry API**: Complete package registry with publish/install/search + +### Unified Architecture +- **Composable Design**: Core infrastructure with protocol plugins +- **Shared Storage**: Cloud-agnostic S3-compatible backend (@push.rocks/smartbucket) +- **Unified Authentication**: Scope-based permissions across both protocols +- **Path-based Routing**: `/oci/*` for containers, `/npm/*` for packages + +### Authentication & Authorization +- NPM UUID tokens for package operations +- OCI JWT tokens for container operations +- Unified scope system: `npm:package:foo:write`, `oci:repository:bar:push` +- Pluggable via async callbacks + +### Comprehensive Feature Set + +**OCI Features:** +- ✅ Pull operations (manifests, blobs) +- ✅ Push operations (chunked uploads) +- ✅ Content discovery (tags, referrers API) +- ✅ Content management (deletion) + +**NPM Features:** +- ✅ Package publish/unpublish +- ✅ Package download (tarballs) +- ✅ Metadata & search +- ✅ Dist-tag management +- ✅ Token management ## Installation @@ -21,31 +43,14 @@ npm install @push.rocks/smartregistry pnpm add @push.rocks/smartregistry ``` -## Usage - -### Basic Setup +## Quick Start ```typescript -import { SmartRegistry, IRegistryConfig, TLoginCallback, TAuthCallback } from '@push.rocks/smartregistry'; +import { SmartRegistry, IRegistryConfig } from '@push.rocks/smartregistry'; -// Implement login callback -const loginCallback: TLoginCallback = async (credentials) => { - // Validate credentials and return JWT token - // This should create a proper JWT with required claims - return generateJWT(credentials.username); -}; - -// Implement authorization callback -const authCallback: TAuthCallback = async (token, repository, action) => { - // Validate token and check permissions - const claims = verifyJWT(token); - return hasPermission(claims, repository, action); -}; - -// Configure registry const config: IRegistryConfig = { storage: { - accessKey: 'your-s3-access-key', + accessKey: 'your-s3-key', accessSecret: 'your-s3-secret', endpoint: 's3.amazonaws.com', port: 443, @@ -53,117 +58,406 @@ const config: IRegistryConfig = { region: 'us-east-1', bucketName: 'my-registry', }, - serviceName: 'my-registry', - tokenRealm: 'https://auth.example.com/token', - loginCallback, - authCallback, + auth: { + jwtSecret: 'your-secret-key', + tokenStore: 'memory', + npmTokens: { enabled: true }, + ociTokens: { + enabled: true, + realm: 'https://auth.example.com/token', + service: 'my-registry', + }, + }, + oci: { + enabled: true, + basePath: '/oci', + }, + npm: { + enabled: true, + basePath: '/npm', + }, }; -// Create and initialize registry const registry = new SmartRegistry(config); await registry.init(); + +// Handle requests +const response = await registry.handleRequest({ + method: 'GET', + path: '/npm/express', + headers: {}, + query: {}, +}); ``` -### Integration with HTTP Server +## Architecture -```typescript -import express from 'express'; +### Directory Structure -const app = express(); - -// OCI Distribution API endpoints -app.get('/v2/', (req, res) => { - res.status(200).json({}); -}); - -app.get('/v2/:name(*)/manifests/:reference', async (req, res) => { - const { name, reference } = req.params; - const token = req.headers.authorization?.replace('Bearer ', ''); - - const result = await registry.getManifest(name, reference, token); - - if ('errors' in result) { - return res.status(404).json(result); - } - - res.setHeader('Content-Type', result.contentType); - res.setHeader('Docker-Content-Digest', result.digest); - res.send(result.data); -}); - -app.get('/v2/:name(*)/blobs/:digest', async (req, res) => { - const { name, digest } = req.params; - const token = req.headers.authorization?.replace('Bearer ', ''); - - const result = await registry.getBlob(name, digest, token); - - if ('errors' in result) { - return res.status(404).json(result); - } - - res.setHeader('Content-Type', 'application/octet-stream'); - res.send(result.data); -}); - -// ... implement other endpoints - -app.listen(5000); +``` +ts/ +├── core/ # Shared infrastructure +│ ├── classes.baseregistry.ts +│ ├── classes.registrystorage.ts +│ ├── classes.authmanager.ts +│ └── interfaces.core.ts +├── oci/ # OCI implementation +│ ├── classes.ociregistry.ts +│ └── interfaces.oci.ts +├── npm/ # NPM implementation +│ ├── classes.npmregistry.ts +│ └── interfaces.npm.ts +└── classes.smartregistry.ts # Main orchestrator ``` -### Authentication Flow +### Request Flow + +``` +HTTP Request + ↓ +SmartRegistry (orchestrator) + ↓ +Path-based routing + ├─→ /oci/* → OciRegistry + └─→ /npm/* → NpmRegistry + ↓ + Shared Storage & Auth + ↓ + S3-compatible backend +``` + +## Usage Examples + +### OCI Registry (Container Images) ```typescript -// Client requests without token -const challenge = registry.getAuthChallenge('library/nginx', ['pull', 'push']); -// Returns: Bearer realm="https://auth.example.com/token",service="my-registry",scope="repository:library/nginx:pull,push" +// Pull an image +const response = await registry.handleRequest({ + method: 'GET', + path: '/oci/v2/library/nginx/manifests/latest', + headers: { + 'Authorization': 'Bearer ', + }, + query: {}, +}); -// Client authenticates -const token = await registry.login({ username: 'user', password: 'pass' }); +// Push a blob +const uploadInit = await registry.handleRequest({ + method: 'POST', + path: '/oci/v2/myapp/blobs/uploads/', + headers: { 'Authorization': 'Bearer ' }, + query: {}, +}); -// Client uses token for subsequent requests -const manifest = await registry.getManifest('library/nginx', 'latest', token); +const uploadId = uploadInit.headers['Docker-Upload-UUID']; + +await registry.handleRequest({ + method: 'PUT', + path: `/oci/v2/myapp/blobs/uploads/${uploadId}`, + headers: { 'Authorization': 'Bearer ' }, + query: { digest: 'sha256:abc123...' }, + body: blobData, +}); +``` + +### NPM Registry (Packages) + +```typescript +// Install a package (get metadata) +const metadata = await registry.handleRequest({ + method: 'GET', + path: '/npm/express', + headers: {}, + query: {}, +}); + +// Download tarball +const tarball = await registry.handleRequest({ + method: 'GET', + path: '/npm/express/-/express-4.18.0.tgz', + headers: {}, + query: {}, +}); + +// Publish a package +const publishResponse = await registry.handleRequest({ + method: 'PUT', + path: '/npm/my-package', + headers: { 'Authorization': 'Bearer ' }, + query: {}, + body: { + name: 'my-package', + versions: { + '1.0.0': { /* version metadata */ }, + }, + 'dist-tags': { latest: '1.0.0' }, + _attachments: { + 'my-package-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: '', + length: 12345, + }, + }, + }, +}); + +// Search packages +const searchResults = await registry.handleRequest({ + method: 'GET', + path: '/npm/-/v1/search', + headers: {}, + query: { text: 'express', size: '20' }, +}); +``` + +### Authentication + +```typescript +// NPM Login +const authManager = registry.getAuthManager(); + +// Authenticate user +const userId = await authManager.authenticate({ + username: 'user', + password: 'pass', +}); + +// Create NPM token +const npmToken = await authManager.createNpmToken(userId, false); + +// Create OCI token with scopes +const ociToken = await authManager.createOciToken( + userId, + ['oci:repository:myapp:push', 'oci:repository:myapp:pull'], + 3600 +); + +// Validate any token +const token = await authManager.validateToken(npmToken, 'npm'); + +// Check permissions +const canWrite = await authManager.authorize( + token, + 'npm:package:my-package', + 'write' +); +``` + +## Configuration + +### Storage Configuration + +```typescript +storage: { + accessKey: string; // S3 access key + accessSecret: string; // S3 secret key + endpoint: string; // S3 endpoint + port?: number; // Default: 443 + useSsl?: boolean; // Default: true + region?: string; // Default: 'us-east-1' + bucketName: string; // Bucket name +} +``` + +### Authentication Configuration + +```typescript +auth: { + jwtSecret: string; // Secret for signing JWTs + tokenStore: 'memory' | 'redis' | 'database'; + npmTokens: { + enabled: boolean; + defaultReadonly?: boolean; + }; + ociTokens: { + enabled: boolean; + realm: string; // Auth server URL + service: string; // Service name + }; +} +``` + +### Protocol Configuration + +```typescript +oci?: { + enabled: boolean; + basePath: string; // Default: '/oci' + features?: { + referrers?: boolean; + deletion?: boolean; + }; +} + +npm?: { + enabled: boolean; + basePath: string; // Default: '/npm' + features?: { + publish?: boolean; + unpublish?: boolean; + search?: boolean; + }; +} ``` ## API Reference -### Pull Operations (Required) +### Core Classes -- `getManifest(repository, reference, token?)` - Download a manifest -- `headManifest(repository, reference, token?)` - Check manifest existence -- `getBlob(repository, digest, token?, range?)` - Download a blob -- `headBlob(repository, digest, token?)` - Check blob existence +#### SmartRegistry -### Push Operations +Main orchestrator class. -- `initiateUpload(repository, token, mountDigest?, fromRepository?)` - Start blob upload -- `uploadChunk(uploadId, data, contentRange, token)` - Upload blob chunk -- `completeUpload(uploadId, digest, token, finalData?)` - Finalize blob upload -- `putManifest(repository, reference, manifest, contentType, token)` - Upload manifest +**Methods:** +- `init()` - Initialize the registry +- `handleRequest(context)` - Handle HTTP request +- `getStorage()` - Get storage instance +- `getAuthManager()` - Get auth manager +- `getRegistry(protocol)` - Get protocol handler -### Content Discovery +#### RegistryStorage -- `listTags(repository, token?, pagination?)` - List all tags -- `getReferrers(repository, digest, token?, artifactType?)` - Get referencing artifacts +Unified storage abstraction. -### Content Management +**OCI Methods:** +- `getOciBlob(digest)` - Get blob +- `putOciBlob(digest, data)` - Store blob +- `getOciManifest(repo, digest)` - Get manifest +- `putOciManifest(repo, digest, data, type)` - Store manifest -- `deleteManifest(repository, digest, token)` - Delete manifest -- `deleteBlob(repository, digest, token)` - Delete blob -- `deleteTag(repository, tag, token)` - Delete tag +**NPM Methods:** +- `getNpmPackument(name)` - Get package metadata +- `putNpmPackument(name, data)` - Store package metadata +- `getNpmTarball(name, version)` - Get tarball +- `putNpmTarball(name, version, data)` - Store tarball -### Authentication +#### AuthManager -- `login(credentials)` - Get authentication token -- `getAuthChallenge(repository, actions)` - Generate WWW-Authenticate header +Unified authentication manager. -## OCI Specification Compliance +**Methods:** +- `authenticate(credentials)` - Validate user credentials +- `createNpmToken(userId, readonly)` - Create NPM token +- `createOciToken(userId, scopes, expiresIn)` - Create OCI JWT +- `validateToken(token, protocol)` - Validate any token +- `authorize(token, resource, action)` - Check permissions -This library implements: +### Protocol Handlers -- **Pull Category** (required): All manifest and blob retrieval operations -- **Push Category**: Complete blob upload workflow with chunked and monolithic modes -- **Content Discovery**: Tag listing and referrers API -- **Content Management**: Deletion operations for manifests, blobs, and tags +#### OciRegistry + +**Endpoints:** +- `GET /v2/` - Version check +- `GET /v2/{name}/manifests/{ref}` - Get manifest +- `PUT /v2/{name}/manifests/{ref}` - Push manifest +- `GET /v2/{name}/blobs/{digest}` - Get blob +- `POST /v2/{name}/blobs/uploads/` - Initiate upload +- `GET /v2/{name}/tags/list` - List tags +- `GET /v2/{name}/referrers/{digest}` - Get referrers + +#### NpmRegistry + +**Endpoints:** +- `GET /{package}` - Get package metadata +- `PUT /{package}` - Publish package +- `GET /{package}/-/{tarball}` - Download tarball +- `GET /-/v1/search` - Search packages +- `PUT /-/user/org.couchdb.user:{user}` - Login +- `GET /-/npm/v1/tokens` - List tokens +- `POST /-/npm/v1/tokens` - Create token +- `PUT /-/package/{pkg}/dist-tags/{tag}` - Update tag + +## Storage Structure + +``` +bucket/ +├── oci/ +│ ├── blobs/ +│ │ └── sha256/{hash} +│ ├── manifests/ +│ │ └── {repository}/{digest} +│ └── tags/ +│ └── {repository}/tags.json +└── npm/ + ├── packages/ + │ ├── {name}/ + │ │ ├── index.json # Packument + │ │ └── {name}-{ver}.tgz # Tarball + │ └── @{scope}/{name}/ + │ ├── index.json + │ └── {name}-{ver}.tgz + └── users/ + └── {username}.json +``` + +## Scope Format + +Unified scope format across protocols: + +``` +{protocol}:{type}:{name}:{action} + +Examples: + npm:package:express:read # Read express package + npm:package:*:write # Write any package + npm:*:* # Full NPM access + oci:repository:nginx:pull # Pull nginx image + oci:repository:*:push # Push any image + oci:*:* # Full OCI access +``` + +## Integration Examples + +### Express Server + +```typescript +import express from 'express'; +import { SmartRegistry } from '@push.rocks/smartregistry'; + +const app = express(); +const registry = new SmartRegistry(config); +await registry.init(); + +app.all('*', async (req, res) => { + const response = await registry.handleRequest({ + method: req.method, + path: req.path, + headers: req.headers as Record, + query: req.query as Record, + body: req.body, + }); + + res.status(response.status); + Object.entries(response.headers).forEach(([key, value]) => { + res.setHeader(key, value); + }); + + if (response.body) { + if (Buffer.isBuffer(response.body)) { + res.send(response.body); + } else { + res.json(response.body); + } + } else { + res.end(); + } +}); + +app.listen(5000); +``` + +## Development + +```bash +# Install dependencies +pnpm install + +# Build +pnpm run build + +# Test +pnpm test +``` ## License @@ -171,4 +465,4 @@ MIT ## Contributing -See the main repository for contribution guidelines. +Contributions welcome! Please see the repository for guidelines. diff --git a/test/test.helper.ts b/test/test.helper.ts new file mode 100644 index 0000000..b694b8b --- /dev/null +++ b/test/test.helper.ts @@ -0,0 +1,149 @@ +import * as qenv from '@push.rocks/qenv'; +import { SmartRegistry, IRegistryConfig } from '../ts/index.js'; + +const testQenv = new qenv.Qenv('./', './.nogit'); + +/** + * Create a test SmartRegistry instance with both OCI and NPM enabled + */ +export async function createTestRegistry(): Promise { + // Read S3 config from env.json + const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESS_KEY'); + const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRET_KEY'); + const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT'); + const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT'); + + const config: IRegistryConfig = { + storage: { + accessKey: s3AccessKey || 'minioadmin', + accessSecret: s3SecretKey || 'minioadmin', + endpoint: s3Endpoint || 'localhost', + port: parseInt(s3Port || '9000', 10), + useSsl: false, + region: 'us-east-1', + bucketName: 'test-registry', + }, + auth: { + jwtSecret: 'test-secret-key', + tokenStore: 'memory', + npmTokens: { + enabled: true, + }, + ociTokens: { + enabled: true, + realm: 'https://auth.example.com/token', + service: 'test-registry', + }, + }, + oci: { + enabled: true, + basePath: '/oci', + }, + npm: { + enabled: true, + basePath: '/npm', + }, + }; + + const registry = new SmartRegistry(config); + await registry.init(); + + return registry; +} + +/** + * Helper to create test authentication tokens + */ +export async function createTestTokens(registry: SmartRegistry) { + const authManager = registry.getAuthManager(); + + // Authenticate and create tokens + const userId = await authManager.authenticate({ + username: 'testuser', + password: 'testpass', + }); + + if (!userId) { + throw new Error('Failed to authenticate test user'); + } + + // Create NPM token + const npmToken = await authManager.createNpmToken(userId, false); + + // Create OCI token with full access + const ociToken = await authManager.createOciToken( + userId, + ['oci:repository:*:*'], + 3600 + ); + + return { npmToken, ociToken, userId }; +} + +/** + * Helper to calculate SHA-256 digest in OCI format + */ +export function calculateDigest(data: Buffer): string { + const crypto = require('crypto'); + const hash = crypto.createHash('sha256').update(data).digest('hex'); + return `sha256:${hash}`; +} + +/** + * Helper to create a minimal valid OCI manifest + */ +export function createTestManifest(configDigest: string, layerDigest: string) { + return { + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.oci.image.config.v1+json', + size: 123, + digest: configDigest, + }, + layers: [ + { + mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip', + size: 456, + digest: layerDigest, + }, + ], + }; +} + +/** + * Helper to create a minimal valid NPM packument + */ +export function createTestPackument(packageName: string, version: string, tarballData: Buffer) { + const crypto = require('crypto'); + const shasum = crypto.createHash('sha1').update(tarballData).digest('hex'); + const integrity = `sha512-${crypto.createHash('sha512').update(tarballData).digest('base64')}`; + + return { + name: packageName, + versions: { + [version]: { + name: packageName, + version: version, + description: 'Test package', + main: 'index.js', + scripts: {}, + dist: { + shasum: shasum, + integrity: integrity, + tarball: `http://localhost:5000/npm/${packageName}/-/${packageName}-${version}.tgz`, + }, + }, + }, + 'dist-tags': { + latest: version, + }, + _attachments: { + [`${packageName}-${version}.tgz`]: { + content_type: 'application/octet-stream', + data: tarballData.toString('base64'), + length: tarballData.length, + }, + }, + }; +} diff --git a/test/test.npm.ts b/test/test.npm.ts new file mode 100644 index 0000000..e69de29 diff --git a/test/test.oci.ts b/test/test.oci.ts new file mode 100644 index 0000000..56983c3 --- /dev/null +++ b/test/test.oci.ts @@ -0,0 +1,297 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { SmartRegistry } from '../ts/index.js'; +import { createTestRegistry, createTestTokens, calculateDigest, createTestManifest } from './test.helper.js'; + +let registry: SmartRegistry; +let ociToken: string; +let testBlobDigest: string; +let testConfigDigest: string; +let testManifestDigest: string; + +// Test data +const testBlobData = Buffer.from('Hello from OCI test blob!', 'utf-8'); +const testConfigData = Buffer.from(JSON.stringify({ arch: 'amd64', os: 'linux' }), 'utf-8'); + +tap.test('OCI: should create registry instance', async () => { + registry = await createTestRegistry(); + const tokens = await createTestTokens(registry); + ociToken = tokens.ociToken; + + expect(registry).toBeInstanceOf(SmartRegistry); + expect(ociToken).toBeTypeOf('string'); +}); + +tap.test('OCI: should handle version check (GET /v2/)', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: '/oci/v2/', + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.headers['Docker-Distribution-API-Version']).toEqual('registry/2.0'); +}); + +tap.test('OCI: should initiate blob upload (POST /v2/{name}/blobs/uploads/)', async () => { + const response = await registry.handleRequest({ + method: 'POST', + path: '/oci/v2/test-repo/blobs/uploads/', + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: {}, + }); + + expect(response.status).toEqual(202); + expect(response.headers).toHaveProperty('Location'); + expect(response.headers).toHaveProperty('Docker-Upload-UUID'); +}); + +tap.test('OCI: should upload blob in single PUT', async () => { + testBlobDigest = calculateDigest(testBlobData); + + const response = await registry.handleRequest({ + method: 'POST', + path: '/oci/v2/test-repo/blobs/uploads/', + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: { + digest: testBlobDigest, + }, + body: testBlobData, + }); + + expect(response.status).toEqual(201); + expect(response.headers).toHaveProperty('Location'); + expect(response.headers['Docker-Content-Digest']).toEqual(testBlobDigest); +}); + +tap.test('OCI: should upload config blob', async () => { + testConfigDigest = calculateDigest(testConfigData); + + const response = await registry.handleRequest({ + method: 'POST', + path: '/oci/v2/test-repo/blobs/uploads/', + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: { + digest: testConfigDigest, + }, + body: testConfigData, + }); + + expect(response.status).toEqual(201); + expect(response.headers['Docker-Content-Digest']).toEqual(testConfigDigest); +}); + +tap.test('OCI: should check if blob exists (HEAD /v2/{name}/blobs/{digest})', async () => { + const response = await registry.handleRequest({ + method: 'HEAD', + path: `/oci/v2/test-repo/blobs/${testBlobDigest}`, + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.headers['Content-Length']).toEqual(testBlobData.length.toString()); + expect(response.headers['Docker-Content-Digest']).toEqual(testBlobDigest); +}); + +tap.test('OCI: should retrieve blob (GET /v2/{name}/blobs/{digest})', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: `/oci/v2/test-repo/blobs/${testBlobDigest}`, + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + expect((response.body as Buffer).toString('utf-8')).toEqual('Hello from OCI test blob!'); + expect(response.headers['Docker-Content-Digest']).toEqual(testBlobDigest); +}); + +tap.test('OCI: should upload manifest (PUT /v2/{name}/manifests/{reference})', async () => { + const manifest = createTestManifest(testConfigDigest, testBlobDigest); + const manifestJson = JSON.stringify(manifest); + const manifestBuffer = Buffer.from(manifestJson, 'utf-8'); + testManifestDigest = calculateDigest(manifestBuffer); + + const response = await registry.handleRequest({ + method: 'PUT', + path: '/oci/v2/test-repo/manifests/v1.0.0', + headers: { + Authorization: `Bearer ${ociToken}`, + 'Content-Type': 'application/vnd.oci.image.manifest.v1+json', + }, + query: {}, + body: manifestBuffer, + }); + + expect(response.status).toEqual(201); + expect(response.headers).toHaveProperty('Location'); + expect(response.headers['Docker-Content-Digest']).toMatch(/^sha256:[a-f0-9]{64}$/); +}); + +tap.test('OCI: should retrieve manifest by tag (GET /v2/{name}/manifests/{reference})', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: '/oci/v2/test-repo/manifests/v1.0.0', + headers: { + Authorization: `Bearer ${ociToken}`, + Accept: 'application/vnd.oci.image.manifest.v1+json', + }, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + + const manifest = JSON.parse((response.body as Buffer).toString('utf-8')); + expect(manifest.schemaVersion).toEqual(2); + expect(manifest.config.digest).toEqual(testConfigDigest); + expect(manifest.layers[0].digest).toEqual(testBlobDigest); +}); + +tap.test('OCI: should retrieve manifest by digest (GET /v2/{name}/manifests/{digest})', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: `/oci/v2/test-repo/manifests/${testManifestDigest}`, + headers: { + Authorization: `Bearer ${ociToken}`, + Accept: 'application/vnd.oci.image.manifest.v1+json', + }, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.headers['Docker-Content-Digest']).toEqual(testManifestDigest); +}); + +tap.test('OCI: should check if manifest exists (HEAD /v2/{name}/manifests/{reference})', async () => { + const response = await registry.handleRequest({ + method: 'HEAD', + path: '/oci/v2/test-repo/manifests/v1.0.0', + headers: { + Authorization: `Bearer ${ociToken}`, + Accept: 'application/vnd.oci.image.manifest.v1+json', + }, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.headers['Docker-Content-Digest']).toMatch(/^sha256:[a-f0-9]{64}$/); +}); + +tap.test('OCI: should list tags (GET /v2/{name}/tags/list)', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: '/oci/v2/test-repo/tags/list', + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toHaveProperty('tags'); + + const tagList = response.body as any; + expect(tagList.name).toEqual('test-repo'); + expect(tagList.tags).toBeInstanceOf(Array); + expect(tagList.tags).toContain('v1.0.0'); +}); + +tap.test('OCI: should handle pagination for tag list', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: '/oci/v2/test-repo/tags/list', + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: { + n: '10', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toHaveProperty('tags'); +}); + +tap.test('OCI: should return 404 for non-existent blob', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: '/oci/v2/test-repo/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000', + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: {}, + }); + + expect(response.status).toEqual(404); + expect(response.body).toHaveProperty('errors'); +}); + +tap.test('OCI: should return 404 for non-existent manifest', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: '/oci/v2/test-repo/manifests/non-existent-tag', + headers: { + Authorization: `Bearer ${ociToken}`, + Accept: 'application/vnd.oci.image.manifest.v1+json', + }, + query: {}, + }); + + expect(response.status).toEqual(404); + expect(response.body).toHaveProperty('errors'); +}); + +tap.test('OCI: should delete manifest (DELETE /v2/{name}/manifests/{digest})', async () => { + const response = await registry.handleRequest({ + method: 'DELETE', + path: `/oci/v2/test-repo/manifests/${testManifestDigest}`, + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: {}, + }); + + expect(response.status).toEqual(202); +}); + +tap.test('OCI: should delete blob (DELETE /v2/{name}/blobs/{digest})', async () => { + const response = await registry.handleRequest({ + method: 'DELETE', + path: `/oci/v2/test-repo/blobs/${testBlobDigest}`, + headers: { + Authorization: `Bearer ${ociToken}`, + }, + query: {}, + }); + + expect(response.status).toEqual(202); +}); + +tap.test('OCI: should handle unauthorized requests', async () => { + const response = await registry.handleRequest({ + method: 'GET', + path: '/oci/v2/test-repo/manifests/v1.0.0', + headers: { + // No authorization header + }, + query: {}, + }); + + expect(response.status).toEqual(401); + expect(response.headers['WWW-Authenticate']).toInclude('Bearer'); +}); + +export default tap.start(); diff --git a/ts/classes.registrystorage.ts b/ts/classes.registrystorage.ts deleted file mode 100644 index 82ac6b3..0000000 --- a/ts/classes.registrystorage.ts +++ /dev/null @@ -1,311 +0,0 @@ -import * as plugins from './plugins.js'; -import { IRegistryConfig, IOciManifest, IOciImageIndex, ITagList } from './interfaces.js'; - -/** - * Storage layer for OCI registry using SmartBucket - */ -export class RegistryStorage { - private smartBucket: plugins.smartbucket.SmartBucket; - private bucket: plugins.smartbucket.Bucket; - private bucketName: string; - - constructor(private config: IRegistryConfig['storage']) { - this.bucketName = config.bucketName; - } - - /** - * Initialize the storage backend - */ - public async init() { - this.smartBucket = new plugins.smartbucket.SmartBucket({ - accessKey: this.config.accessKey, - accessSecret: this.config.accessSecret, - endpoint: this.config.endpoint, - port: this.config.port || 443, - useSsl: this.config.useSsl !== false, - region: this.config.region || 'us-east-1', - }); - - // Ensure bucket exists - await this.smartBucket.createBucket(this.bucketName).catch(() => { - // Bucket may already exist, that's fine - }); - - this.bucket = await this.smartBucket.getBucketByName(this.bucketName); - } - - /** - * Store a blob - * @param digest - Content digest (e.g., "sha256:abc123...") - * @param data - Blob data - */ - public async putBlob(digest: string, data: Buffer): Promise { - const path = this.getBlobPath(digest); - await this.bucket.fastPut({ - path, - contents: data, - }); - } - - /** - * Retrieve a blob - * @param digest - Content digest - * @returns Blob data or null if not found - */ - public async getBlob(digest: string): Promise { - const path = this.getBlobPath(digest); - try { - return await this.bucket.fastGet({ path }); - } catch (error) { - return null; - } - } - - /** - * Check if blob exists - * @param digest - Content digest - * @returns true if exists - */ - public async blobExists(digest: string): Promise { - const path = this.getBlobPath(digest); - return await this.bucket.fastExists({ path }); - } - - /** - * Delete a blob - * @param digest - Content digest - */ - public async deleteBlob(digest: string): Promise { - const path = this.getBlobPath(digest); - await this.bucket.fastRemove({ path }); - } - - /** - * Store a manifest - * @param repository - Repository name (e.g., "library/nginx") - * @param reference - Tag or digest - * @param manifest - Manifest content - * @param contentType - Manifest media type - */ - public async putManifest( - repository: string, - reference: string, - manifest: IOciManifest | IOciImageIndex, - contentType: string - ): Promise { - const manifestJson = JSON.stringify(manifest); - const manifestBuffer = Buffer.from(manifestJson, 'utf-8'); - - // Calculate digest - const digest = await this.calculateDigest(manifestBuffer); - - // Store by digest - const digestPath = this.getManifestPath(repository, digest); - await this.bucket.fastPut({ - path: digestPath, - contents: manifestBuffer, - meta: { 'Content-Type': contentType }, - }); - - // If reference is a tag (not a digest), create/update tag mapping - if (!reference.startsWith('sha256:')) { - await this.putTag(repository, reference, digest); - } - - return digest; - } - - /** - * Retrieve a manifest - * @param repository - Repository name - * @param reference - Tag or digest - * @returns Manifest data and content type, or null if not found - */ - public async getManifest( - repository: string, - reference: string - ): Promise<{ data: Buffer; contentType: string } | null> { - let digest = reference; - - // If reference is a tag, resolve to digest - if (!reference.startsWith('sha256:')) { - const resolvedDigest = await this.getTagDigest(repository, reference); - if (!resolvedDigest) return null; - digest = resolvedDigest; - } - - const path = this.getManifestPath(repository, digest); - try { - const data = await this.bucket.fastGet({ path }); - // TODO: Retrieve content type from metadata if SmartBucket supports it - const contentType = 'application/vnd.oci.image.manifest.v1+json'; - return { data, contentType }; - } catch (error) { - return null; - } - } - - /** - * Check if manifest exists - * @param repository - Repository name - * @param reference - Tag or digest - * @returns true if exists - */ - public async manifestExists(repository: string, reference: string): Promise { - let digest = reference; - - // If reference is a tag, resolve to digest - if (!reference.startsWith('sha256:')) { - const resolvedDigest = await this.getTagDigest(repository, reference); - if (!resolvedDigest) return false; - digest = resolvedDigest; - } - - const path = this.getManifestPath(repository, digest); - return await this.bucket.fastExists({ path }); - } - - /** - * Delete a manifest - * @param repository - Repository name - * @param digest - Manifest digest (must be digest, not tag) - */ - public async deleteManifest(repository: string, digest: string): Promise { - const path = this.getManifestPath(repository, digest); - await this.bucket.fastRemove({ path }); - } - - /** - * Store tag mapping - * @param repository - Repository name - * @param tag - Tag name - * @param digest - Manifest digest - */ - public async putTag(repository: string, tag: string, digest: string): Promise { - const tags = await this.getTags(repository); - tags[tag] = digest; - - const path = this.getTagsPath(repository); - await this.bucket.fastPut({ - path, - contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'), - }); - } - - /** - * Get digest for a tag - * @param repository - Repository name - * @param tag - Tag name - * @returns Digest or null if tag doesn't exist - */ - public async getTagDigest(repository: string, tag: string): Promise { - const tags = await this.getTags(repository); - return tags[tag] || null; - } - - /** - * List all tags for a repository - * @param repository - Repository name - * @returns Tag list - */ - public async listTags(repository: string): Promise { - const tags = await this.getTags(repository); - return Object.keys(tags); - } - - /** - * Delete a tag - * @param repository - Repository name - * @param tag - Tag name - */ - public async deleteTag(repository: string, tag: string): Promise { - const tags = await this.getTags(repository); - delete tags[tag]; - - const path = this.getTagsPath(repository); - await this.bucket.fastPut({ - path, - contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'), - }); - } - - /** - * Get all manifests that reference a specific digest (referrers API) - * @param repository - Repository name - * @param digest - Subject digest - * @returns Array of manifest digests - */ - public async getReferrers(repository: string, digest: string): Promise { - // This is a simplified implementation - // In production, you'd want to maintain an index - const referrersPath = this.getReferrersPath(repository, digest); - try { - const data = await this.bucket.fastGet({ path: referrersPath }); - const referrers = JSON.parse(data.toString('utf-8')); - return referrers; - } catch (error) { - return []; - } - } - - /** - * Add a referrer relationship - * @param repository - Repository name - * @param subjectDigest - Digest being referenced - * @param referrerDigest - Digest of the referrer - */ - public async addReferrer( - repository: string, - subjectDigest: string, - referrerDigest: string - ): Promise { - const referrers = await this.getReferrers(repository, subjectDigest); - if (!referrers.includes(referrerDigest)) { - referrers.push(referrerDigest); - } - - const path = this.getReferrersPath(repository, subjectDigest); - await this.bucket.fastPut({ - path, - contents: Buffer.from(JSON.stringify(referrers, null, 2), 'utf-8'), - }); - } - - // Helper methods - - private getBlobPath(digest: string): string { - // Remove algorithm prefix for path (sha256:abc -> abc) - const hash = digest.split(':')[1]; - return `blobs/sha256/${hash}`; - } - - private getManifestPath(repository: string, digest: string): string { - const hash = digest.split(':')[1]; - return `manifests/${repository}/${hash}`; - } - - private getTagsPath(repository: string): string { - return `tags/${repository}/tags.json`; - } - - private getReferrersPath(repository: string, digest: string): string { - const hash = digest.split(':')[1]; - return `referrers/${repository}/${hash}.json`; - } - - private async getTags(repository: string): Promise<{ [tag: string]: string }> { - const path = this.getTagsPath(repository); - try { - const data = await this.bucket.fastGet({ path }); - return JSON.parse(data.toString('utf-8')); - } catch (error) { - return {}; - } - } - - private async calculateDigest(data: Buffer): Promise { - const crypto = await import('crypto'); - const hash = crypto.createHash('sha256').update(data).digest('hex'); - return `sha256:${hash}`; - } -} diff --git a/ts/classes.smartregistry.ts b/ts/classes.smartregistry.ts index e15ade5..f6229c7 100644 --- a/ts/classes.smartregistry.ts +++ b/ts/classes.smartregistry.ts @@ -1,747 +1,118 @@ -import * as plugins from './plugins.js'; -import { RegistryStorage } from './classes.registrystorage.js'; -import { - IRegistryConfig, - IUploadSession, - IOciManifest, - IOciImageIndex, - ITagList, - IReferrersResponse, - IRegistryError, - IPaginationOptions, - TRegistryAction, -} from './interfaces.js'; +import { RegistryStorage } from './core/classes.registrystorage.js'; +import { AuthManager } from './core/classes.authmanager.js'; +import { BaseRegistry } from './core/classes.baseregistry.js'; +import type { IRegistryConfig, IRequestContext, IResponse } from './core/interfaces.core.js'; +import { OciRegistry } from './oci/classes.ociregistry.js'; +import { NpmRegistry } from './npm/classes.npmregistry.js'; /** - * Main OCI Distribution Specification compliant registry class - * This class provides all the methods needed to implement an OCI registry - * and can be integrated into any HTTP server + * Main registry orchestrator + * Routes requests to appropriate protocol handlers (OCI or NPM) */ export class SmartRegistry { private storage: RegistryStorage; + private authManager: AuthManager; + private registries: Map = new Map(); private config: IRegistryConfig; - private uploadSessions: Map = new Map(); private initialized: boolean = false; constructor(config: IRegistryConfig) { this.config = config; this.storage = new RegistryStorage(config.storage); + this.authManager = new AuthManager(config.auth); } /** - * Initialize the registry (must be called before use) + * Initialize the registry system */ public async init(): Promise { if (this.initialized) return; + + // Initialize storage await this.storage.init(); + + // Initialize auth manager + await this.authManager.init(); + + // Initialize OCI registry if enabled + if (this.config.oci?.enabled) { + const ociBasePath = this.config.oci.basePath || '/oci'; + const ociRegistry = new OciRegistry(this.storage, this.authManager, ociBasePath); + await ociRegistry.init(); + this.registries.set('oci', ociRegistry); + } + + // Initialize NPM registry if enabled + if (this.config.npm?.enabled) { + const npmBasePath = this.config.npm.basePath || '/npm'; + const registryUrl = `http://localhost:5000${npmBasePath}`; // TODO: Make configurable + const npmRegistry = new NpmRegistry(this.storage, this.authManager, npmBasePath, registryUrl); + await npmRegistry.init(); + this.registries.set('npm', npmRegistry); + } + this.initialized = true; - - // Start cleanup of stale upload sessions - this.startUploadSessionCleanup(); } - // ======================================================================== - // PULL OPERATIONS (Required by OCI spec) - // ======================================================================== - /** - * GET /v2/{name}/manifests/{reference} - * Retrieve a manifest by tag or digest - * @param repository - Repository name (e.g., "library/nginx") - * @param reference - Tag name or digest - * @param token - Optional bearer token for authentication - * @returns Manifest content and metadata + * Handle an incoming HTTP request + * Routes to the appropriate protocol handler based on path */ - public async getManifest( - repository: string, - reference: string, - token?: string - ): Promise<{ data: Buffer; contentType: string; digest: string } | IRegistryError> { - // Check authorization - if (token) { - const authorized = await this.config.authCallback(token, repository, 'pull'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); + public async handleRequest(context: IRequestContext): Promise { + const path = context.path; + + // Route to OCI registry + if (this.config.oci?.enabled && path.startsWith(this.config.oci.basePath)) { + const ociRegistry = this.registries.get('oci'); + if (ociRegistry) { + return ociRegistry.handleRequest(context); } } - const result = await this.storage.getManifest(repository, reference); - if (!result) { - return this.createError('MANIFEST_UNKNOWN', 'Manifest not found'); + // Route to NPM registry + if (this.config.npm?.enabled && path.startsWith(this.config.npm.basePath)) { + const npmRegistry = this.registries.get('npm'); + if (npmRegistry) { + return npmRegistry.handleRequest(context); + } } - // Calculate digest if not already known - const digest = await this.calculateDigest(result.data); - + // No matching registry return { - data: result.data, - contentType: result.contentType, - digest, + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: { + error: 'NOT_FOUND', + message: 'No registry handler for this path', + }, }; } /** - * HEAD /v2/{name}/manifests/{reference} - * Check if a manifest exists without downloading it - * @param repository - Repository name - * @param reference - Tag name or digest - * @param token - Optional bearer token - * @returns Metadata if exists, error otherwise + * Get the storage instance (for testing/advanced use) */ - public async headManifest( - repository: string, - reference: string, - token?: string - ): Promise<{ exists: true; digest: string; contentType: string } | IRegistryError> { - // Check authorization - if (token) { - const authorized = await this.config.authCallback(token, repository, 'pull'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - } - - const exists = await this.storage.manifestExists(repository, reference); - if (!exists) { - return this.createError('MANIFEST_UNKNOWN', 'Manifest not found'); - } - - // Get manifest to calculate digest and content type - const result = await this.storage.getManifest(repository, reference); - if (!result) { - return this.createError('MANIFEST_UNKNOWN', 'Manifest not found'); - } - - const digest = await this.calculateDigest(result.data); - - return { - exists: true, - digest, - contentType: result.contentType, - }; + public getStorage(): RegistryStorage { + return this.storage; } /** - * GET /v2/{name}/blobs/{digest} - * Download a blob - * @param repository - Repository name - * @param digest - Blob digest - * @param token - Optional bearer token - * @param range - Optional HTTP range header (e.g., "bytes=0-1023") - * @returns Blob data + * Get the auth manager instance (for testing/advanced use) */ - public async getBlob( - repository: string, - digest: string, - token?: string, - range?: string - ): Promise<{ data: Buffer; contentType: string } | IRegistryError> { - // Check authorization - if (token) { - const authorized = await this.config.authCallback(token, repository, 'pull'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - } - - const data = await this.storage.getBlob(digest); - if (!data) { - return this.createError('BLOB_UNKNOWN', 'Blob not found'); - } - - // Handle range requests - let responseData = data; - if (range) { - const rangeMatch = range.match(/bytes=(\d+)-(\d*)/); - if (rangeMatch) { - const start = parseInt(rangeMatch[1], 10); - const end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) + 1 : data.length; - responseData = data.slice(start, end); - } - } - - return { - data: responseData, - contentType: 'application/octet-stream', - }; + public getAuthManager(): AuthManager { + return this.authManager; } /** - * HEAD /v2/{name}/blobs/{digest} - * Check if a blob exists - * @param repository - Repository name - * @param digest - Blob digest - * @param token - Optional bearer token - * @returns Metadata if exists + * Get a specific registry handler */ - public async headBlob( - repository: string, - digest: string, - token?: string - ): Promise<{ exists: true; size: number } | IRegistryError> { - // Check authorization - if (token) { - const authorized = await this.config.authCallback(token, repository, 'pull'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - } - - const exists = await this.storage.blobExists(digest); - if (!exists) { - return this.createError('BLOB_UNKNOWN', 'Blob not found'); - } - - // Get blob to determine size - const data = await this.storage.getBlob(digest); - if (!data) { - return this.createError('BLOB_UNKNOWN', 'Blob not found'); - } - - return { - exists: true, - size: data.length, - }; - } - - // ======================================================================== - // PUSH OPERATIONS - // ======================================================================== - - /** - * POST /v2/{name}/blobs/uploads/ - * Initiate a blob upload session - * @param repository - Repository name - * @param token - Bearer token - * @param mountDigest - Optional digest to mount from another repository - * @param fromRepository - Source repository for mount - * @returns Upload session ID and location - */ - public async initiateUpload( - repository: string, - token: string, - mountDigest?: string, - fromRepository?: string - ): Promise<{ uploadId: string; location: string } | IRegistryError> { - // Check authorization - const authorized = await this.config.authCallback(token, repository, 'push'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - - // Handle blob mount if requested - if (mountDigest && fromRepository) { - const mountResult = await this.mountBlob( - repository, - mountDigest, - fromRepository, - token - ); - if ('location' in mountResult) { - return mountResult; - } - // If mount fails, continue with normal upload - } - - // Create upload session - const uploadId = this.generateUploadId(); - const session: IUploadSession = { - uploadId, - repository, - chunks: [], - totalSize: 0, - createdAt: new Date(), - lastActivity: new Date(), - }; - - this.uploadSessions.set(uploadId, session); - - return { - uploadId, - location: `/v2/${repository}/blobs/uploads/${uploadId}`, - }; + public getRegistry(protocol: 'oci' | 'npm'): BaseRegistry | undefined { + return this.registries.get(protocol); } /** - * PATCH /v2/{name}/blobs/uploads/{uuid} - * Upload a chunk of data to an upload session - * @param uploadId - Upload session ID - * @param data - Chunk data - * @param contentRange - Content-Range header (e.g., "0-1023") - * @param token - Bearer token - * @returns Updated upload status + * Check if the registry is initialized */ - public async uploadChunk( - uploadId: string, - data: Buffer, - contentRange: string, - token: string - ): Promise<{ location: string; range: string } | IRegistryError> { - const session = this.uploadSessions.get(uploadId); - if (!session) { - return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'); - } - - // Check authorization - const authorized = await this.config.authCallback(token, session.repository, 'push'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - - // Parse content range - const rangeMatch = contentRange.match(/(\d+)-(\d+)/); - if (!rangeMatch) { - return this.createError('BLOB_UPLOAD_INVALID', 'Invalid content range'); - } - - const start = parseInt(rangeMatch[1], 10); - const end = parseInt(rangeMatch[2], 10); - - // Validate sequential upload - if (start !== session.totalSize) { - return this.createError( - 'BLOB_UPLOAD_INVALID', - 'Chunks must be uploaded sequentially' - ); - } - - // Add chunk - session.chunks.push(data); - session.totalSize += data.length; - session.lastActivity = new Date(); - - return { - location: `/v2/${session.repository}/blobs/uploads/${uploadId}`, - range: `0-${session.totalSize - 1}`, - }; - } - - /** - * PUT /v2/{name}/blobs/uploads/{uuid}?digest={digest} - * Complete a chunked upload or upload a monolithic blob - * @param uploadId - Upload session ID (use 'monolithic' for single request upload) - * @param digest - Final blob digest - * @param token - Bearer token - * @param finalData - Optional final chunk data - * @returns Blob location - */ - public async completeUpload( - uploadId: string, - digest: string, - token: string, - finalData?: Buffer - ): Promise<{ location: string; digest: string } | IRegistryError> { - let repository: string; - let blobData: Buffer; - - if (uploadId === 'monolithic') { - // Monolithic upload - data is in finalData - if (!finalData) { - return this.createError('BLOB_UPLOAD_INVALID', 'No data provided'); - } - // For monolithic uploads, we need repository from somewhere - // This is a simplified version - in practice, you'd pass repository explicitly - blobData = finalData; - repository = 'temp'; // This needs to be properly handled - } else { - // Chunked upload - const session = this.uploadSessions.get(uploadId); - if (!session) { - return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'); - } - - repository = session.repository; - - // Check authorization - const authorized = await this.config.authCallback(token, repository, 'push'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - - // Combine all chunks - const chunks = [...session.chunks]; - if (finalData) { - chunks.push(finalData); - } - blobData = Buffer.concat(chunks); - - // Clean up session - this.uploadSessions.delete(uploadId); - } - - // Verify digest - const calculatedDigest = await this.calculateDigest(blobData); - if (calculatedDigest !== digest) { - return this.createError('DIGEST_INVALID', 'Digest mismatch'); - } - - // Store blob - await this.storage.putBlob(digest, blobData); - - return { - location: `/v2/${repository}/blobs/${digest}`, - digest, - }; - } - - /** - * GET /v2/{name}/blobs/uploads/{uuid} - * Get the status of an upload session - * @param uploadId - Upload session ID - * @param token - Bearer token - * @returns Upload status - */ - public async getUploadStatus( - uploadId: string, - token: string - ): Promise<{ location: string; range: string } | IRegistryError> { - const session = this.uploadSessions.get(uploadId); - if (!session) { - return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'); - } - - // Check authorization - const authorized = await this.config.authCallback(token, session.repository, 'push'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - - return { - location: `/v2/${session.repository}/blobs/uploads/${uploadId}`, - range: session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0', - }; - } - - /** - * PUT /v2/{name}/manifests/{reference} - * Upload a manifest - * @param repository - Repository name - * @param reference - Tag or digest - * @param manifest - Manifest object - * @param contentType - Manifest media type - * @param token - Bearer token - * @returns Manifest location and digest - */ - public async putManifest( - repository: string, - reference: string, - manifest: IOciManifest | IOciImageIndex, - contentType: string, - token: string - ): Promise<{ location: string; digest: string } | IRegistryError> { - // Check authorization - const authorized = await this.config.authCallback(token, repository, 'push'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - - // Store manifest - const digest = await this.storage.putManifest(repository, reference, manifest, contentType); - - // If manifest has a subject, add referrer relationship - if ('subject' in manifest && manifest.subject) { - await this.storage.addReferrer(repository, manifest.subject.digest, digest); - } - - return { - location: `/v2/${repository}/manifests/${digest}`, - digest, - }; - } - - // ======================================================================== - // CONTENT DISCOVERY - // ======================================================================== - - /** - * GET /v2/{name}/tags/list - * List all tags for a repository - * @param repository - Repository name - * @param token - Optional bearer token - * @param pagination - Pagination options - * @returns Tag list - */ - public async listTags( - repository: string, - token?: string, - pagination?: IPaginationOptions - ): Promise { - // Check authorization - if (token) { - const authorized = await this.config.authCallback(token, repository, 'pull'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - } - - let tags = await this.storage.listTags(repository); - - // Apply pagination - if (pagination) { - tags.sort(); - - if (pagination.last) { - const lastIndex = tags.indexOf(pagination.last); - if (lastIndex >= 0) { - tags = tags.slice(lastIndex + 1); - } - } - - if (pagination.n) { - tags = tags.slice(0, pagination.n); - } - } - - return { - name: repository, - tags, - }; - } - - /** - * GET /v2/{name}/referrers/{digest} - * Get manifests that reference a specific digest - * @param repository - Repository name - * @param digest - Subject digest - * @param token - Optional bearer token - * @param artifactType - Optional filter by artifact type - * @returns Referrers list - */ - public async getReferrers( - repository: string, - digest: string, - token?: string, - artifactType?: string - ): Promise { - // Check authorization - if (token) { - const authorized = await this.config.authCallback(token, repository, 'pull'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - } - - const referrerDigests = await this.storage.getReferrers(repository, digest); - - // Build response with manifest descriptors - const manifests = []; - for (const refDigest of referrerDigests) { - const result = await this.storage.getManifest(repository, refDigest); - if (result) { - const manifest = JSON.parse(result.data.toString('utf-8')); - - // Apply artifact type filter if specified - if (artifactType && manifest.artifactType !== artifactType) { - continue; - } - - manifests.push({ - mediaType: result.contentType, - size: result.data.length, - digest: refDigest, - artifactType: manifest.artifactType, - annotations: manifest.annotations, - }); - } - } - - return { - schemaVersion: 2, - mediaType: 'application/vnd.oci.image.index.v1+json', - manifests, - }; - } - - // ======================================================================== - // CONTENT MANAGEMENT (Deletion) - // ======================================================================== - - /** - * DELETE /v2/{name}/manifests/{digest} - * Delete a manifest by digest - * @param repository - Repository name - * @param digest - Manifest digest (must be digest, not tag) - * @param token - Bearer token - * @returns Success or error - */ - public async deleteManifest( - repository: string, - digest: string, - token: string - ): Promise<{ success: true } | IRegistryError> { - // Ensure reference is a digest, not a tag - if (!digest.startsWith('sha256:')) { - return this.createError( - 'UNSUPPORTED', - 'Manifest deletion requires digest reference' - ); - } - - // Check authorization - const authorized = await this.config.authCallback(token, repository, 'delete'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - - // Check if manifest exists - const exists = await this.storage.manifestExists(repository, digest); - if (!exists) { - return this.createError('MANIFEST_UNKNOWN', 'Manifest not found'); - } - - // Delete the manifest - await this.storage.deleteManifest(repository, digest); - - return { success: true }; - } - - /** - * DELETE /v2/{name}/blobs/{digest} - * Delete a blob - * @param repository - Repository name - * @param digest - Blob digest - * @param token - Bearer token - * @returns Success or error - */ - public async deleteBlob( - repository: string, - digest: string, - token: string - ): Promise<{ success: true } | IRegistryError> { - // Check authorization - const authorized = await this.config.authCallback(token, repository, 'delete'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - - // Check if blob exists - const exists = await this.storage.blobExists(digest); - if (!exists) { - return this.createError('BLOB_UNKNOWN', 'Blob not found'); - } - - // Delete the blob - await this.storage.deleteBlob(digest); - - return { success: true }; - } - - /** - * DELETE /v2/{name}/tags/{reference} - * Delete a tag - * @param repository - Repository name - * @param tag - Tag name - * @param token - Bearer token - * @returns Success or error - */ - public async deleteTag( - repository: string, - tag: string, - token: string - ): Promise<{ success: true } | IRegistryError> { - // Check authorization - const authorized = await this.config.authCallback(token, repository, 'delete'); - if (!authorized) { - return this.createError('DENIED', 'Insufficient permissions'); - } - - // Delete the tag - await this.storage.deleteTag(repository, tag); - - return { success: true }; - } - - // ======================================================================== - // AUTHENTICATION HELPERS - // ======================================================================== - - /** - * Generate WWW-Authenticate challenge header for 401 responses - * @param repository - Repository name - * @param actions - Required actions - * @returns WWW-Authenticate header value - */ - public getAuthChallenge(repository: string, actions: TRegistryAction[]): string { - const scope = `repository:${repository}:${actions.join(',')}`; - return `Bearer realm="${this.config.tokenRealm}",service="${this.config.serviceName}",scope="${scope}"`; - } - - /** - * Handle login request - * @param credentials - User credentials - * @returns JWT token - */ - public async login(credentials: { username: string; password: string }): Promise { - return await this.config.loginCallback(credentials); - } - - // ======================================================================== - // HELPER METHODS - // ======================================================================== - - /** - * Mount a blob from another repository - */ - private async mountBlob( - targetRepository: string, - digest: string, - sourceRepository: string, - token: string - ): Promise<{ location: string; digest: string } | IRegistryError> { - // Check if blob exists in source - const exists = await this.storage.blobExists(digest); - if (!exists) { - return this.createError('BLOB_UNKNOWN', 'Source blob not found'); - } - - // In a true cross-repository mount, you'd verify the blob belongs to sourceRepository - // For simplicity, we're just checking if it exists - - return { - location: `/v2/${targetRepository}/blobs/${digest}`, - digest, - }; - } - - /** - * Generate a unique upload session ID - */ - private generateUploadId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - } - - /** - * Calculate SHA256 digest of data - */ - private async calculateDigest(data: Buffer): Promise { - const crypto = await import('crypto'); - const hash = crypto.createHash('sha256').update(data).digest('hex'); - return `sha256:${hash}`; - } - - /** - * Create a standard registry error response - */ - private createError(code: string, message: string, detail?: any): IRegistryError { - return { - errors: [{ code, message, detail }], - }; - } - - /** - * Start periodic cleanup of stale upload sessions - */ - private startUploadSessionCleanup(): void { - // Clean up sessions older than 1 hour every 10 minutes - setInterval(() => { - const now = new Date(); - const maxAge = 60 * 60 * 1000; // 1 hour - - for (const [uploadId, session] of this.uploadSessions.entries()) { - if (now.getTime() - session.lastActivity.getTime() > maxAge) { - this.uploadSessions.delete(uploadId); - } - } - }, 10 * 60 * 1000); // Run every 10 minutes + public isInitialized(): boolean { + return this.initialized; } } diff --git a/ts/core/classes.authmanager.ts b/ts/core/classes.authmanager.ts index ad0bde9..595104a 100644 --- a/ts/core/classes.authmanager.ts +++ b/ts/core/classes.authmanager.ts @@ -1,4 +1,4 @@ -import { IAuthConfig, IAuthToken, ICredentials, TRegistryProtocol } from './interfaces.core.js'; +import type { IAuthConfig, IAuthToken, ICredentials, TRegistryProtocol } from './interfaces.core.js'; /** * Unified authentication manager for all registry protocols diff --git a/ts/core/classes.baseregistry.ts b/ts/core/classes.baseregistry.ts index 3eca86f..b36f44c 100644 --- a/ts/core/classes.baseregistry.ts +++ b/ts/core/classes.baseregistry.ts @@ -1,4 +1,4 @@ -import { IRequestContext, IResponse, IAuthToken } from './interfaces.core.js'; +import type { IRequestContext, IResponse, IAuthToken } from './interfaces.core.js'; /** * Abstract base class for all registry protocol implementations diff --git a/ts/core/classes.registrystorage.ts b/ts/core/classes.registrystorage.ts index 23527b6..00d02ad 100644 --- a/ts/core/classes.registrystorage.ts +++ b/ts/core/classes.registrystorage.ts @@ -1,5 +1,5 @@ import * as plugins from '../plugins.js'; -import { IStorageConfig, IStorageBackend } from './interfaces.core.js'; +import type { IStorageConfig, IStorageBackend } from './interfaces.core.js'; /** * Storage abstraction layer for registry @@ -54,10 +54,10 @@ export class RegistryStorage implements IStorageBackend { data: Buffer, metadata?: Record ): Promise { + // Note: SmartBucket doesn't support metadata yet await this.bucket.fastPut({ path: key, contents: data, - meta: metadata, }); } @@ -77,7 +77,7 @@ export class RegistryStorage implements IStorageBackend { if (!dir) return []; const files = await dir.listFiles(); - return files.map(f => f.path); + return files.map(f => f.getBasePath()); } /** diff --git a/ts/index.ts b/ts/index.ts index d19e117..c166583 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,8 +1,16 @@ -import * as plugins from './plugins.js'; +/** + * @push.rocks/smartregistry + * Composable registry supporting OCI and NPM protocols + */ -// Export main classes +// Main orchestrator export { SmartRegistry } from './classes.smartregistry.js'; -export { RegistryStorage } from './classes.registrystorage.js'; -// Export interfaces and types -export * from './interfaces.js'; +// Core infrastructure +export * from './core/index.js'; + +// OCI Registry +export * from './oci/index.js'; + +// NPM Registry +export * from './npm/index.js'; diff --git a/ts/interfaces.ts b/ts/interfaces.ts deleted file mode 100644 index 8725148..0000000 --- a/ts/interfaces.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Interfaces and types for OCI Distribution Specification compliant registry - */ - -/** - * Credentials for authentication - */ -export interface IRegistryCredentials { - username: string; - password: string; -} - -/** - * Actions that can be performed on a repository - */ -export type TRegistryAction = 'pull' | 'push' | 'delete' | '*'; - -/** - * JWT token structure for OCI registry authentication - */ -export interface IRegistryToken { - /** Issuer */ - iss: string; - /** Subject (user identifier) */ - sub: string; - /** Audience (service name) */ - aud: string; - /** Expiration timestamp */ - exp: number; - /** Not before timestamp */ - nbf: number; - /** Issued at timestamp */ - iat: number; - /** JWT ID */ - jti?: string; - /** Access permissions */ - access: Array<{ - type: 'repository' | 'registry'; - name: string; - actions: TRegistryAction[]; - }>; -} - -/** - * Callback function for user login - returns JWT token - * @param credentials - User credentials - * @returns JWT token string - */ -export type TLoginCallback = ( - credentials: IRegistryCredentials -) => Promise; - -/** - * Callback function for authorization check - * @param token - JWT token string - * @param repository - Repository name (e.g., "library/nginx") - * @param action - Action to perform - * @returns true if authorized, false otherwise - */ -export type TAuthCallback = ( - token: string, - repository: string, - action: TRegistryAction -) => Promise; - -/** - * Configuration for the registry - */ -export interface IRegistryConfig { - /** Storage bucket configuration */ - storage: { - accessKey: string; - accessSecret: string; - endpoint: string; - port?: number; - useSsl?: boolean; - region?: string; - bucketName: string; - }; - /** Service name for token authentication */ - serviceName: string; - /** Token realm (authorization server URL) */ - tokenRealm: string; - /** Login callback */ - loginCallback: TLoginCallback; - /** Authorization callback */ - authCallback: TAuthCallback; -} - -/** - * OCI manifest structure - */ -export interface IOciManifest { - schemaVersion: number; - mediaType: string; - config: { - mediaType: string; - size: number; - digest: string; - }; - layers: Array<{ - mediaType: string; - size: number; - digest: string; - urls?: string[]; - }>; - subject?: { - mediaType: string; - size: number; - digest: string; - }; - annotations?: { [key: string]: string }; -} - -/** - * OCI Image Index (manifest list) - */ -export interface IOciImageIndex { - schemaVersion: number; - mediaType: string; - manifests: Array<{ - mediaType: string; - size: number; - digest: string; - platform?: { - architecture: string; - os: string; - 'os.version'?: string; - 'os.features'?: string[]; - variant?: string; - features?: string[]; - }; - annotations?: { [key: string]: string }; - }>; - subject?: { - mediaType: string; - size: number; - digest: string; - }; - annotations?: { [key: string]: string }; -} - -/** - * Upload session for chunked blob uploads - */ -export interface IUploadSession { - uploadId: string; - repository: string; - chunks: Buffer[]; - totalSize: number; - createdAt: Date; - lastActivity: Date; -} - -/** - * Tag list response - */ -export interface ITagList { - name: string; - tags: string[]; -} - -/** - * Referrers response - */ -export interface IReferrersResponse { - schemaVersion: number; - mediaType: string; - manifests: Array<{ - mediaType: string; - size: number; - digest: string; - artifactType?: string; - annotations?: { [key: string]: string }; - }>; -} - -/** - * Registry error response - */ -export interface IRegistryError { - errors: Array<{ - code: string; - message: string; - detail?: any; - }>; -} - -/** - * Pagination options for listing - */ -export interface IPaginationOptions { - /** Maximum number of results to return */ - n?: number; - /** Last entry from previous request */ - last?: string; -} diff --git a/ts/npm/classes.npmregistry.ts b/ts/npm/classes.npmregistry.ts new file mode 100644 index 0000000..0449e64 --- /dev/null +++ b/ts/npm/classes.npmregistry.ts @@ -0,0 +1,707 @@ +import { BaseRegistry } from '../core/classes.baseregistry.js'; +import { RegistryStorage } from '../core/classes.registrystorage.js'; +import { AuthManager } from '../core/classes.authmanager.js'; +import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js'; +import type { + IPackument, + INpmVersion, + IPublishRequest, + ISearchResponse, + ISearchResult, + ITokenListResponse, + ITokenCreateRequest, + IUserAuthRequest, + INpmError, +} from './interfaces.npm.js'; + +/** + * NPM Registry implementation + * Compliant with npm registry API + */ +export class NpmRegistry extends BaseRegistry { + private storage: RegistryStorage; + private authManager: AuthManager; + private basePath: string = '/npm'; + private registryUrl: string; + + constructor( + storage: RegistryStorage, + authManager: AuthManager, + basePath: string = '/npm', + registryUrl: string = 'http://localhost:5000/npm' + ) { + super(); + this.storage = storage; + this.authManager = authManager; + this.basePath = basePath; + this.registryUrl = registryUrl; + } + + public async init(): Promise { + // NPM registry initialization + } + + public getBasePath(): string { + return this.basePath; + } + + public async handleRequest(context: IRequestContext): Promise { + const path = context.path.replace(this.basePath, ''); + + // Extract token from Authorization header + const authHeader = context.headers['authorization'] || context.headers['Authorization']; + const tokenString = authHeader?.replace(/^Bearer\s+/i, ''); + const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null; + + // Registry root + if (path === '/' || path === '') { + return this.handleRegistryInfo(); + } + + // Search: /-/v1/search + if (path.startsWith('/-/v1/search')) { + return this.handleSearch(context.query); + } + + // User authentication: /-/user/org.couchdb.user:{username} + const userMatch = path.match(/^\/-\/user\/org\.couchdb\.user:(.+)$/); + if (userMatch) { + return this.handleUserAuth(context.method, userMatch[1], context.body, token); + } + + // Token operations: /-/npm/v1/tokens + if (path.startsWith('/-/npm/v1/tokens')) { + return this.handleTokens(context.method, path, context.body, token); + } + + // Dist-tags: /-/package/{package}/dist-tags + const distTagsMatch = path.match(/^\/-\/package\/(@?[^\/]+(?:\/[^\/]+)?)\/dist-tags(?:\/(.+))?$/); + if (distTagsMatch) { + const [, packageName, tag] = distTagsMatch; + return this.handleDistTags(context.method, packageName, tag, context.body, token); + } + + // Tarball download: /{package}/-/{filename}.tgz + const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/); + if (tarballMatch) { + const [, packageName, filename] = tarballMatch; + return this.handleTarballDownload(packageName, filename, token); + } + + // Package operations: /{package} + const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/); + if (packageMatch) { + const packageName = packageMatch[1]; + return this.handlePackage(context.method, packageName, context.body, context.query, token); + } + + // Package version: /{package}/{version} + const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/); + if (versionMatch) { + const [, packageName, version] = versionMatch; + return this.handlePackageVersion(packageName, version, token); + } + + return { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('E404', 'Not found'), + }; + } + + protected async checkPermission( + token: IAuthToken | null, + resource: string, + action: string + ): Promise { + if (!token) return false; + return this.authManager.authorize(token, `npm:package:${resource}`, action); + } + + // ======================================================================== + // REQUEST HANDLERS + // ======================================================================== + + private handleRegistryInfo(): IResponse { + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + db_name: 'registry', + doc_count: 0, + doc_del_count: 0, + update_seq: 0, + purge_seq: 0, + compact_running: false, + disk_size: 0, + data_size: 0, + instance_start_time: Date.now().toString(), + disk_format_version: 0, + committed_update_seq: 0, + }, + }; + } + + private async handlePackage( + method: string, + packageName: string, + body: any, + query: Record, + token: IAuthToken | null + ): Promise { + switch (method) { + case 'GET': + return this.getPackument(packageName, token, query); + case 'PUT': + return this.publishPackage(packageName, body, token); + case 'DELETE': + return this.unpublishPackage(packageName, token); + default: + return { + status: 405, + headers: {}, + body: this.createError('EBADREQUEST', 'Method not allowed'), + }; + } + } + + private async getPackument( + packageName: string, + token: IAuthToken | null, + query: Record + ): Promise { + const packument = await this.storage.getNpmPackument(packageName); + if (!packument) { + return { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('E404', `Package '${packageName}' not found`), + }; + } + + // Check if abbreviated version requested + const accept = query['accept'] || ''; + if (accept.includes('application/vnd.npm.install-v1+json')) { + // Return abbreviated packument + const abbreviated = { + name: packument.name, + modified: packument.time?.modified || new Date().toISOString(), + 'dist-tags': packument['dist-tags'], + versions: packument.versions, + }; + + return { + status: 200, + headers: { 'Content-Type': 'application/vnd.npm.install-v1+json' }, + body: abbreviated, + }; + } + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: packument, + }; + } + + private async handlePackageVersion( + packageName: string, + version: string, + token: IAuthToken | null + ): Promise { + const packument = await this.storage.getNpmPackument(packageName); + if (!packument) { + return { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('E404', 'Package not found'), + }; + } + + // Resolve version (could be "latest" or actual version) + let actualVersion = version; + if (version === 'latest') { + actualVersion = packument['dist-tags']?.latest; + if (!actualVersion) { + return { + status: 404, + headers: {}, + body: this.createError('E404', 'No latest version'), + }; + } + } + + const versionData = packument.versions[actualVersion]; + if (!versionData) { + return { + status: 404, + headers: {}, + body: this.createError('E404', 'Version not found'), + }; + } + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: versionData, + }; + } + + private async publishPackage( + packageName: string, + body: IPublishRequest, + token: IAuthToken | null + ): Promise { + if (!await this.checkPermission(token, packageName, 'write')) { + return { + status: 401, + headers: {}, + body: this.createError('EUNAUTHORIZED', 'Unauthorized'), + }; + } + + if (!body || !body.versions || !body._attachments) { + return { + status: 400, + headers: {}, + body: this.createError('EBADREQUEST', 'Invalid publish request'), + }; + } + + // Get existing packument or create new one + let packument = await this.storage.getNpmPackument(packageName); + const isNew = !packument; + + if (isNew) { + packument = { + _id: packageName, + name: packageName, + description: body.description, + 'dist-tags': body['dist-tags'] || { latest: Object.keys(body.versions)[0] }, + versions: {}, + time: { + created: new Date().toISOString(), + modified: new Date().toISOString(), + }, + maintainers: body.maintainers || [], + readme: body.readme, + }; + } + + // Process each new version + for (const [version, versionData] of Object.entries(body.versions)) { + // Check if version already exists + if (packument.versions[version]) { + return { + status: 403, + headers: {}, + body: this.createError('EPUBLISHCONFLICT', `Version ${version} already exists`), + }; + } + + // Find attachment for this version + const attachmentKey = Object.keys(body._attachments).find(key => + key.includes(version) + ); + + if (!attachmentKey) { + return { + status: 400, + headers: {}, + body: this.createError('EBADREQUEST', `No tarball for version ${version}`), + }; + } + + const attachment = body._attachments[attachmentKey]; + + // Decode base64 tarball + const tarballBuffer = Buffer.from(attachment.data, 'base64'); + + // Calculate shasum + const crypto = await import('crypto'); + const shasum = crypto.createHash('sha1').update(tarballBuffer).digest('hex'); + const integrity = `sha512-${crypto.createHash('sha512').update(tarballBuffer).digest('base64')}`; + + // Store tarball + await this.storage.putNpmTarball(packageName, version, tarballBuffer); + + // Update version data with dist info + const safeName = packageName.replace('@', '').replace('/', '-'); + versionData.dist = { + tarball: `${this.registryUrl}/${packageName}/-/${safeName}-${version}.tgz`, + shasum, + integrity, + fileCount: 0, + unpackedSize: tarballBuffer.length, + }; + + versionData._id = `${packageName}@${version}`; + versionData._npmUser = token ? { name: token.userId, email: '' } : undefined; + + // Add version to packument + packument.versions[version] = versionData; + if (packument.time) { + packument.time[version] = new Date().toISOString(); + packument.time.modified = new Date().toISOString(); + } + } + + // Update dist-tags + if (body['dist-tags']) { + packument['dist-tags'] = { ...packument['dist-tags'], ...body['dist-tags'] }; + } + + // Save packument + await this.storage.putNpmPackument(packageName, packument); + + return { + status: 201, + headers: { 'Content-Type': 'application/json' }, + body: { ok: true, id: packageName, rev: packument._rev || '1-' + Date.now() }, + }; + } + + private async unpublishPackage( + packageName: string, + token: IAuthToken | null + ): Promise { + if (!await this.checkPermission(token, packageName, 'delete')) { + return { + status: 401, + headers: {}, + body: this.createError('EUNAUTHORIZED', 'Unauthorized'), + }; + } + + const packument = await this.storage.getNpmPackument(packageName); + if (!packument) { + return { + status: 404, + headers: {}, + body: this.createError('E404', 'Package not found'), + }; + } + + // Delete all tarballs + for (const version of Object.keys(packument.versions)) { + await this.storage.deleteNpmTarball(packageName, version); + } + + // Delete packument + await this.storage.deleteNpmPackument(packageName); + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { ok: true }, + }; + } + + private async handleTarballDownload( + packageName: string, + filename: string, + token: IAuthToken | null + ): Promise { + // Extract version from filename: package-name-1.0.0.tgz + const versionMatch = filename.match(/-([\d.]+(?:-[a-z0-9.]+)?)\.tgz$/); + if (!versionMatch) { + return { + status: 400, + headers: {}, + body: this.createError('EBADREQUEST', 'Invalid tarball filename'), + }; + } + + const version = versionMatch[1]; + const tarball = await this.storage.getNpmTarball(packageName, version); + + if (!tarball) { + return { + status: 404, + headers: {}, + body: this.createError('E404', 'Tarball not found'), + }; + } + + return { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': tarball.length.toString(), + }, + body: tarball, + }; + } + + private async handleSearch(query: Record): Promise { + const text = query.text || ''; + const size = parseInt(query.size || '20', 10); + const from = parseInt(query.from || '0', 10); + + // Simple search implementation (in production, use proper search index) + const results: ISearchResult[] = []; + + // For now, return empty results + // In production, implement full-text search across packuments + + const response: ISearchResponse = { + objects: results, + total: results.length, + time: new Date().toISOString(), + }; + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: response, + }; + } + + private async handleUserAuth( + method: string, + username: string, + body: IUserAuthRequest, + token: IAuthToken | null + ): Promise { + if (method !== 'PUT') { + return { + status: 405, + headers: {}, + body: this.createError('EBADREQUEST', 'Method not allowed'), + }; + } + + if (!body || !body.name || !body.password) { + return { + status: 400, + headers: {}, + body: this.createError('EBADREQUEST', 'Invalid request'), + }; + } + + // Authenticate user + const userId = await this.authManager.authenticate({ + username: body.name, + password: body.password, + }); + + if (!userId) { + return { + status: 401, + headers: {}, + body: this.createError('EUNAUTHORIZED', 'Invalid credentials'), + }; + } + + // Create NPM token + const npmToken = await this.authManager.createNpmToken(userId, false); + + return { + status: 201, + headers: { 'Content-Type': 'application/json' }, + body: { + ok: true, + id: `org.couchdb.user:${username}`, + rev: '1-' + Date.now(), + token: npmToken, + }, + }; + } + + private async handleTokens( + method: string, + path: string, + body: any, + token: IAuthToken | null + ): Promise { + if (!token) { + return { + status: 401, + headers: {}, + body: this.createError('EUNAUTHORIZED', 'Unauthorized'), + }; + } + + // List tokens: GET /-/npm/v1/tokens + if (path === '/-/npm/v1/tokens' && method === 'GET') { + return this.listTokens(token); + } + + // Create token: POST /-/npm/v1/tokens + if (path === '/-/npm/v1/tokens' && method === 'POST') { + return this.createToken(body, token); + } + + // Delete token: DELETE /-/npm/v1/tokens/token/{key} + const deleteMatch = path.match(/^\/-\/npm\/v1\/tokens\/token\/(.+)$/); + if (deleteMatch && method === 'DELETE') { + return this.deleteToken(deleteMatch[1], token); + } + + return { + status: 404, + headers: {}, + body: this.createError('E404', 'Not found'), + }; + } + + private async listTokens(token: IAuthToken): Promise { + const tokens = await this.authManager.listUserTokens(token.userId); + + const response: ITokenListResponse = { + objects: tokens.map(t => ({ + token: '********', + key: t.key, + readonly: t.readonly, + created: t.created, + updated: t.created, + })), + total: tokens.length, + urls: {}, + }; + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: response, + }; + } + + private async createToken(body: ITokenCreateRequest, token: IAuthToken): Promise { + if (!body || !body.password) { + return { + status: 400, + headers: {}, + body: this.createError('EBADREQUEST', 'Password required'), + }; + } + + // Verify password (simplified - in production, verify against stored password) + const readonly = body.readonly || false; + const newToken = await this.authManager.createNpmToken(token.userId, readonly); + + return { + status: 201, + headers: { 'Content-Type': 'application/json' }, + body: { + token: newToken, + key: 'sha512-' + newToken.substring(0, 16) + '...', + cidr_whitelist: body.cidr_whitelist || [], + readonly, + created: new Date().toISOString(), + updated: new Date().toISOString(), + }, + }; + } + + private async deleteToken(key: string, token: IAuthToken): Promise { + // In production, lookup token by key hash and delete + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { ok: true }, + }; + } + + private async handleDistTags( + method: string, + packageName: string, + tag: string | undefined, + body: any, + token: IAuthToken | null + ): Promise { + const packument = await this.storage.getNpmPackument(packageName); + if (!packument) { + return { + status: 404, + headers: {}, + body: this.createError('E404', 'Package not found'), + }; + } + + // GET /-/package/{package}/dist-tags + if (method === 'GET' && !tag) { + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: packument['dist-tags'] || {}, + }; + } + + // PUT /-/package/{package}/dist-tags/{tag} + if (method === 'PUT' && tag) { + if (!await this.checkPermission(token, packageName, 'write')) { + return { + status: 401, + headers: {}, + body: this.createError('EUNAUTHORIZED', 'Unauthorized'), + }; + } + + if (typeof body !== 'string') { + return { + status: 400, + headers: {}, + body: this.createError('EBADREQUEST', 'Version string required'), + }; + } + + packument['dist-tags'] = packument['dist-tags'] || {}; + packument['dist-tags'][tag] = body; + await this.storage.putNpmPackument(packageName, packument); + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { ok: true }, + }; + } + + // DELETE /-/package/{package}/dist-tags/{tag} + if (method === 'DELETE' && tag) { + if (!await this.checkPermission(token, packageName, 'write')) { + return { + status: 401, + headers: {}, + body: this.createError('EUNAUTHORIZED', 'Unauthorized'), + }; + } + + if (tag === 'latest') { + return { + status: 403, + headers: {}, + body: this.createError('EFORBIDDEN', 'Cannot delete latest tag'), + }; + } + + if (packument['dist-tags'] && packument['dist-tags'][tag]) { + delete packument['dist-tags'][tag]; + await this.storage.putNpmPackument(packageName, packument); + } + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: { ok: true }, + }; + } + + return { + status: 405, + headers: {}, + body: this.createError('EBADREQUEST', 'Method not allowed'), + }; + } + + // ======================================================================== + // HELPER METHODS + // ======================================================================== + + private createError(code: string, message: string): INpmError { + return { + error: code, + reason: message, + }; + } +} diff --git a/ts/npm/index.ts b/ts/npm/index.ts new file mode 100644 index 0000000..b7fd083 --- /dev/null +++ b/ts/npm/index.ts @@ -0,0 +1,6 @@ +/** + * NPM Registry module exports + */ + +export { NpmRegistry } from './classes.npmregistry.js'; +export * from './interfaces.npm.js'; diff --git a/ts/oci/classes.ociregistry.ts b/ts/oci/classes.ociregistry.ts index 5db0577..67310e1 100644 --- a/ts/oci/classes.ociregistry.ts +++ b/ts/oci/classes.ociregistry.ts @@ -1,8 +1,8 @@ import { BaseRegistry } from '../core/classes.baseregistry.js'; import { RegistryStorage } from '../core/classes.registrystorage.js'; import { AuthManager } from '../core/classes.authmanager.js'; -import { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js'; -import { +import type { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js'; +import type { IUploadSession, IOciManifest, IOciImageIndex, diff --git a/ts/oci/classes.ocistorage.ts b/ts/oci/classes.ocistorage.ts deleted file mode 100644 index 82ac6b3..0000000 --- a/ts/oci/classes.ocistorage.ts +++ /dev/null @@ -1,311 +0,0 @@ -import * as plugins from './plugins.js'; -import { IRegistryConfig, IOciManifest, IOciImageIndex, ITagList } from './interfaces.js'; - -/** - * Storage layer for OCI registry using SmartBucket - */ -export class RegistryStorage { - private smartBucket: plugins.smartbucket.SmartBucket; - private bucket: plugins.smartbucket.Bucket; - private bucketName: string; - - constructor(private config: IRegistryConfig['storage']) { - this.bucketName = config.bucketName; - } - - /** - * Initialize the storage backend - */ - public async init() { - this.smartBucket = new plugins.smartbucket.SmartBucket({ - accessKey: this.config.accessKey, - accessSecret: this.config.accessSecret, - endpoint: this.config.endpoint, - port: this.config.port || 443, - useSsl: this.config.useSsl !== false, - region: this.config.region || 'us-east-1', - }); - - // Ensure bucket exists - await this.smartBucket.createBucket(this.bucketName).catch(() => { - // Bucket may already exist, that's fine - }); - - this.bucket = await this.smartBucket.getBucketByName(this.bucketName); - } - - /** - * Store a blob - * @param digest - Content digest (e.g., "sha256:abc123...") - * @param data - Blob data - */ - public async putBlob(digest: string, data: Buffer): Promise { - const path = this.getBlobPath(digest); - await this.bucket.fastPut({ - path, - contents: data, - }); - } - - /** - * Retrieve a blob - * @param digest - Content digest - * @returns Blob data or null if not found - */ - public async getBlob(digest: string): Promise { - const path = this.getBlobPath(digest); - try { - return await this.bucket.fastGet({ path }); - } catch (error) { - return null; - } - } - - /** - * Check if blob exists - * @param digest - Content digest - * @returns true if exists - */ - public async blobExists(digest: string): Promise { - const path = this.getBlobPath(digest); - return await this.bucket.fastExists({ path }); - } - - /** - * Delete a blob - * @param digest - Content digest - */ - public async deleteBlob(digest: string): Promise { - const path = this.getBlobPath(digest); - await this.bucket.fastRemove({ path }); - } - - /** - * Store a manifest - * @param repository - Repository name (e.g., "library/nginx") - * @param reference - Tag or digest - * @param manifest - Manifest content - * @param contentType - Manifest media type - */ - public async putManifest( - repository: string, - reference: string, - manifest: IOciManifest | IOciImageIndex, - contentType: string - ): Promise { - const manifestJson = JSON.stringify(manifest); - const manifestBuffer = Buffer.from(manifestJson, 'utf-8'); - - // Calculate digest - const digest = await this.calculateDigest(manifestBuffer); - - // Store by digest - const digestPath = this.getManifestPath(repository, digest); - await this.bucket.fastPut({ - path: digestPath, - contents: manifestBuffer, - meta: { 'Content-Type': contentType }, - }); - - // If reference is a tag (not a digest), create/update tag mapping - if (!reference.startsWith('sha256:')) { - await this.putTag(repository, reference, digest); - } - - return digest; - } - - /** - * Retrieve a manifest - * @param repository - Repository name - * @param reference - Tag or digest - * @returns Manifest data and content type, or null if not found - */ - public async getManifest( - repository: string, - reference: string - ): Promise<{ data: Buffer; contentType: string } | null> { - let digest = reference; - - // If reference is a tag, resolve to digest - if (!reference.startsWith('sha256:')) { - const resolvedDigest = await this.getTagDigest(repository, reference); - if (!resolvedDigest) return null; - digest = resolvedDigest; - } - - const path = this.getManifestPath(repository, digest); - try { - const data = await this.bucket.fastGet({ path }); - // TODO: Retrieve content type from metadata if SmartBucket supports it - const contentType = 'application/vnd.oci.image.manifest.v1+json'; - return { data, contentType }; - } catch (error) { - return null; - } - } - - /** - * Check if manifest exists - * @param repository - Repository name - * @param reference - Tag or digest - * @returns true if exists - */ - public async manifestExists(repository: string, reference: string): Promise { - let digest = reference; - - // If reference is a tag, resolve to digest - if (!reference.startsWith('sha256:')) { - const resolvedDigest = await this.getTagDigest(repository, reference); - if (!resolvedDigest) return false; - digest = resolvedDigest; - } - - const path = this.getManifestPath(repository, digest); - return await this.bucket.fastExists({ path }); - } - - /** - * Delete a manifest - * @param repository - Repository name - * @param digest - Manifest digest (must be digest, not tag) - */ - public async deleteManifest(repository: string, digest: string): Promise { - const path = this.getManifestPath(repository, digest); - await this.bucket.fastRemove({ path }); - } - - /** - * Store tag mapping - * @param repository - Repository name - * @param tag - Tag name - * @param digest - Manifest digest - */ - public async putTag(repository: string, tag: string, digest: string): Promise { - const tags = await this.getTags(repository); - tags[tag] = digest; - - const path = this.getTagsPath(repository); - await this.bucket.fastPut({ - path, - contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'), - }); - } - - /** - * Get digest for a tag - * @param repository - Repository name - * @param tag - Tag name - * @returns Digest or null if tag doesn't exist - */ - public async getTagDigest(repository: string, tag: string): Promise { - const tags = await this.getTags(repository); - return tags[tag] || null; - } - - /** - * List all tags for a repository - * @param repository - Repository name - * @returns Tag list - */ - public async listTags(repository: string): Promise { - const tags = await this.getTags(repository); - return Object.keys(tags); - } - - /** - * Delete a tag - * @param repository - Repository name - * @param tag - Tag name - */ - public async deleteTag(repository: string, tag: string): Promise { - const tags = await this.getTags(repository); - delete tags[tag]; - - const path = this.getTagsPath(repository); - await this.bucket.fastPut({ - path, - contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'), - }); - } - - /** - * Get all manifests that reference a specific digest (referrers API) - * @param repository - Repository name - * @param digest - Subject digest - * @returns Array of manifest digests - */ - public async getReferrers(repository: string, digest: string): Promise { - // This is a simplified implementation - // In production, you'd want to maintain an index - const referrersPath = this.getReferrersPath(repository, digest); - try { - const data = await this.bucket.fastGet({ path: referrersPath }); - const referrers = JSON.parse(data.toString('utf-8')); - return referrers; - } catch (error) { - return []; - } - } - - /** - * Add a referrer relationship - * @param repository - Repository name - * @param subjectDigest - Digest being referenced - * @param referrerDigest - Digest of the referrer - */ - public async addReferrer( - repository: string, - subjectDigest: string, - referrerDigest: string - ): Promise { - const referrers = await this.getReferrers(repository, subjectDigest); - if (!referrers.includes(referrerDigest)) { - referrers.push(referrerDigest); - } - - const path = this.getReferrersPath(repository, subjectDigest); - await this.bucket.fastPut({ - path, - contents: Buffer.from(JSON.stringify(referrers, null, 2), 'utf-8'), - }); - } - - // Helper methods - - private getBlobPath(digest: string): string { - // Remove algorithm prefix for path (sha256:abc -> abc) - const hash = digest.split(':')[1]; - return `blobs/sha256/${hash}`; - } - - private getManifestPath(repository: string, digest: string): string { - const hash = digest.split(':')[1]; - return `manifests/${repository}/${hash}`; - } - - private getTagsPath(repository: string): string { - return `tags/${repository}/tags.json`; - } - - private getReferrersPath(repository: string, digest: string): string { - const hash = digest.split(':')[1]; - return `referrers/${repository}/${hash}.json`; - } - - private async getTags(repository: string): Promise<{ [tag: string]: string }> { - const path = this.getTagsPath(repository); - try { - const data = await this.bucket.fastGet({ path }); - return JSON.parse(data.toString('utf-8')); - } catch (error) { - return {}; - } - } - - private async calculateDigest(data: Buffer): Promise { - const crypto = await import('crypto'); - const hash = crypto.createHash('sha256').update(data).digest('hex'); - return `sha256:${hash}`; - } -} diff --git a/ts/oci/index.ts b/ts/oci/index.ts index 2ae4552..e0d5585 100644 --- a/ts/oci/index.ts +++ b/ts/oci/index.ts @@ -3,4 +3,4 @@ */ export { OciRegistry } from './classes.ociregistry.js'; -export * from './interfaces.oci.ts'; +export * from './interfaces.oci.js'; diff --git a/ts/oci/interfaces.oci.ts b/ts/oci/interfaces.oci.ts index 8725148..e96f86d 100644 --- a/ts/oci/interfaces.oci.ts +++ b/ts/oci/interfaces.oci.ts @@ -1,92 +1,7 @@ /** - * Interfaces and types for OCI Distribution Specification compliant registry + * OCI Distribution Specification specific interfaces */ -/** - * Credentials for authentication - */ -export interface IRegistryCredentials { - username: string; - password: string; -} - -/** - * Actions that can be performed on a repository - */ -export type TRegistryAction = 'pull' | 'push' | 'delete' | '*'; - -/** - * JWT token structure for OCI registry authentication - */ -export interface IRegistryToken { - /** Issuer */ - iss: string; - /** Subject (user identifier) */ - sub: string; - /** Audience (service name) */ - aud: string; - /** Expiration timestamp */ - exp: number; - /** Not before timestamp */ - nbf: number; - /** Issued at timestamp */ - iat: number; - /** JWT ID */ - jti?: string; - /** Access permissions */ - access: Array<{ - type: 'repository' | 'registry'; - name: string; - actions: TRegistryAction[]; - }>; -} - -/** - * Callback function for user login - returns JWT token - * @param credentials - User credentials - * @returns JWT token string - */ -export type TLoginCallback = ( - credentials: IRegistryCredentials -) => Promise; - -/** - * Callback function for authorization check - * @param token - JWT token string - * @param repository - Repository name (e.g., "library/nginx") - * @param action - Action to perform - * @returns true if authorized, false otherwise - */ -export type TAuthCallback = ( - token: string, - repository: string, - action: TRegistryAction -) => Promise; - -/** - * Configuration for the registry - */ -export interface IRegistryConfig { - /** Storage bucket configuration */ - storage: { - accessKey: string; - accessSecret: string; - endpoint: string; - port?: number; - useSsl?: boolean; - region?: string; - bucketName: string; - }; - /** Service name for token authentication */ - serviceName: string; - /** Token realm (authorization server URL) */ - tokenRealm: string; - /** Login callback */ - loginCallback: TLoginCallback; - /** Authorization callback */ - authCallback: TAuthCallback; -} - /** * OCI manifest structure */ @@ -175,17 +90,6 @@ export interface IReferrersResponse { }>; } -/** - * Registry error response - */ -export interface IRegistryError { - errors: Array<{ - code: string; - message: string; - detail?: any; - }>; -} - /** * Pagination options for listing */