@push.rocks/smartregistry
One TypeScript registry core for OCI, npm, Maven, Cargo, Composer, PyPI, and RubyGems.
@push.rocks/smartregistry is a composable library for building your own multi-protocol package registry. You hand it HTTP requests, it routes them to the right protocol handler, stores artifacts in S3-compatible object storage, enforces shared auth scopes, and can proxy/cache upstream registries when content is not local.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Why It Hits
- One registry engine, seven ecosystems.
- Shared S3-backed storage instead of protocol-specific silos.
- Shared auth, token minting, and scope checks across protocols.
- Optional upstream proxying with retries, stale caching, negative caching, and circuit breakers.
- Clean public API:
handleRequest()in,IResponseout.
What It Actually Ships
| Protocol | Paths | What works | Auth style |
|---|---|---|---|
| OCI | /oci/* or /v2/* |
version check, blobs, manifests, tags, referrers, deletes | Bearer JWT |
| npm | /npm/* |
login, publish, packuments, version metadata, tarballs, dist-tags, search, token APIs, unpublish | Bearer token |
| Maven | /maven/* |
POM/JAR/WAR upload and download, maven-metadata.xml, auto-generated checksums, delete |
Bearer token or Basic auth with the token as password |
| Cargo | /cargo/* |
sparse index, config.json, publish, download, search, yank, unyank |
plain Authorization token |
| Composer | /composer/* |
packages.json, p2 metadata, ZIP dists, filtered package lists, upload, version delete, package delete |
Bearer token, plus Basic auth for credential-backed reads |
| PyPI | /simple/* and /pypi/* |
PEP 503 HTML, PEP 691 JSON, upload, JSON metadata API, downloads, package delete, version delete | Bearer token or Basic __token__:<token> |
| RubyGems | /rubygems/* |
Compact Index, gem downloads, versions/dependencies JSON, specs endpoints, upload, yank, unyank | plain Authorization token |
Use oci.basePath = '/v2' if you want native Docker/OCI client compatibility. The default /oci path is fine for app-level routing, but Docker expects /v2.
Set pypi.registryUrl to the host root, not /pypi, because the Simple API lives at /simple/* while uploads and JSON endpoints live under /pypi/*.
Install
pnpm add @push.rocks/smartregistry
Quick Start
import { SmartRegistry, type IRegistryConfig } from '@push.rocks/smartregistry';
const publicUrl = 'https://registry.example.com';
const config: IRegistryConfig = {
storage: {
accessKey: process.env.S3_ACCESS_KEY!,
accessSecret: process.env.S3_ACCESS_SECRET!,
endpoint: 's3.example.com',
port: 443,
useSsl: true,
region: 'eu-central-1',
bucketName: 'registry-artifacts',
},
auth: {
jwtSecret: process.env.REGISTRY_JWT_SECRET!,
tokenStore: 'memory',
npmTokens: { enabled: true },
ociTokens: {
enabled: true,
realm: `${publicUrl}/v2/token`,
service: 'smartregistry',
},
pypiTokens: { enabled: true },
rubygemsTokens: { enabled: true },
},
oci: { enabled: true, basePath: '/v2' },
npm: { enabled: true, basePath: '/npm', registryUrl: `${publicUrl}/npm` },
maven: { enabled: true, basePath: '/maven', registryUrl: `${publicUrl}/maven` },
cargo: { enabled: true, basePath: '/cargo', registryUrl: `${publicUrl}/cargo` },
composer: { enabled: true, basePath: '/composer', registryUrl: `${publicUrl}/composer` },
pypi: { enabled: true, basePath: '/pypi', registryUrl: publicUrl },
rubygems: { enabled: true, basePath: '/rubygems', registryUrl: `${publicUrl}/rubygems` },
};
const registry = new SmartRegistry(config);
await registry.init();
const auth = registry.getAuthManager();
const npmToken = await auth.createNpmToken('ci-bot');
const cargoToken = await auth.createCargoToken('ci-bot');
const ociToken = await auth.createOciToken('ci-bot', ['oci:repository:myorg/myapp:*']);
console.log({ npmToken, cargoToken, ociToken });
If you do not pass authProvider, the library uses DefaultAuthProvider, an in-memory reference implementation. That is perfect for tests and local dev, but you will usually want a real IAuthProvider in production.
DefaultAuthProvider signs OCI tokens as HS256 JWTs with iss set to ociTokens.realm and aud set to ociTokens.service, and accepts only tokens that carry those two values, an HS256 header, a signature made with jwtSecret and a numeric exp that has not passed. A token signed with the same secret for another realm or service is refused. While ociTokens.enabled is false or ociTokens is missing, AuthManager validates no OCI token at all: it refuses an OCI validation without asking the provider and discards any oci token a provider returns, so a custom IAuthProvider is held to the same switch that already stops createOciToken() from minting.
HTTP Integration
SmartRegistry is not a web framework. You own the HTTP server, request parsing, and response writing. The happy path is very small:
import { COMPOSER_ZIP_LIMITS } from '@push.rocks/smartregistry';
import { createServer, type IncomingHttpHeaders } from 'node:http';
import { Readable } from 'node:stream';
function headersToRecord(headers: IncomingHttpHeaders): Record<string, string> {
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [
key,
Array.isArray(value) ? value.join(', ') : value ?? '',
])
);
}
createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const headers = headersToRecord(req.headers);
const isComposerUpload =
req.method === 'PUT' && url.pathname.startsWith('/composer/packages/');
const maxBodyBytes = isComposerUpload
? COMPOSER_ZIP_LIMITS.maxArchiveBytes
: Number.POSITIVE_INFINITY;
const chunks: Buffer[] = [];
let bodyBytes = 0;
for await (const chunk of req) {
const bodyChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bodyBytes += bodyChunk.byteLength;
if (bodyBytes > maxBodyBytes) {
res.writeHead(413, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 'error', message: 'Request body exceeds upload limit' }));
return;
}
chunks.push(bodyChunk);
}
const rawBody = Buffer.concat(chunks);
let body: unknown = rawBody.length ? rawBody : undefined;
if ((headers['content-type'] ?? '').includes('application/json') && rawBody.length) {
body = JSON.parse(rawBody.toString('utf8'));
}
const response = await registry.handleRequest({
method: req.method ?? 'GET',
path: url.pathname,
query: Object.fromEntries(url.searchParams),
headers,
body,
rawBody: rawBody.length ? rawBody : undefined,
});
res.writeHead(response.status, response.headers);
if (!response.body) {
res.end();
return;
}
Readable.fromWeb(response.body).pipe(res);
}).listen(3000);
Keep rawBody for OCI manifests, OCI blobs, and any other digest-sensitive request where exact bytes matter.
For PyPI uploads, parse multipart/form-data before calling handleRequest() and pass the parsed fields in context.body. The library expects the upload form fields, not a raw multipart buffer.
At the public API boundary, response.body is always a ReadableStream<Uint8Array>.
Core API
| API | Why you use it |
|---|---|
new SmartRegistry(config) |
build the registry orchestrator |
await registry.init() |
initialize storage, auth, and enabled protocols |
await registry.handleRequest(context) |
route one incoming HTTP request |
registry.getAuthManager() |
mint, validate, revoke, and authorize tokens |
registry.getStorage() |
reach the shared storage abstraction directly |
registry.getRegistry(protocol) |
access a specific protocol handler |
await registry.close() |
release timers, protocol resources and the object storage client |
registry.destroy() |
superseded by close(); cleans up timers and protocol resources only |
Shut a registry down with await registry.close(). It is idempotent, and it closes the object storage client that RegistryStorage opened in init() — a process that only calls destroy() keeps that connection open and does not exit. After close(), every storage operation refuses with a RegistryStorageClosedError naming the attempted operation, so a closed registry can never be mistaken for an empty one. destroy() is kept for this major version.
The package also exports the protocol-specific registry classes, upstream classes, RegistryStorage, RegistryStorageClosedError, AuthManager, DefaultAuthProvider, StaticUpstreamProvider, UpstreamCache, CircuitBreaker, and stream helpers such as streamToBuffer() and streamToJson().
Configuration Reference
| Key | Purpose |
|---|---|
storage |
S3-compatible backend config. This extends IS3Descriptor and adds bucketName. |
auth |
shared token settings across protocols |
authProvider |
plug in LDAP, OAuth, OIDC, custom DB-backed auth, or anything else implementing IAuthProvider |
storageHooks |
receive before/after put/get/delete callbacks with protocol, actor, package, and version context |
upstreamProvider |
decide per request which upstream registries to consult |
oci / npm / maven / cargo / composer / pypi / rubygems |
enable protocols, set base paths, and define the public registry URL they should emit |
Upstream Proxying
If a package, image, crate, or artifact does not exist locally, a protocol handler can resolve an upstream config on the fly and fetch it from there.
import {
StaticUpstreamProvider,
} from '@push.rocks/smartregistry';
const upstreamProvider = new StaticUpstreamProvider({
npm: {
enabled: true,
upstreams: [
{
id: 'npmjs',
name: 'npmjs',
url: 'https://registry.npmjs.org',
priority: 1,
enabled: true,
auth: { type: 'none' },
},
],
},
oci: {
enabled: true,
upstreams: [
{
id: 'dockerhub',
name: 'dockerhub',
url: 'https://registry-1.docker.io',
priority: 1,
enabled: true,
auth: { type: 'none' },
},
],
},
});
Pass that provider as upstreamProvider in your IRegistryConfig.
The upstream layer supports scope rules, per-request routing, retries with backoff, circuit breakers, stale-while-revalidate caching, and negative caching for 404s.
Custom Auth and Audit Hooks
Bring your own auth system by implementing IAuthProvider and passing it as authProvider.
const registry = new SmartRegistry({
...config,
authProvider: myAuthProvider,
});
Use storageHooks when you need quota checks, audit logs, or side effects around artifact writes and deletes.
const registry = new SmartRegistry({
...config,
storageHooks: {
async beforePut(context) {
if ((context.metadata?.size ?? 0) > 500 * 1024 * 1024) {
return { allowed: false, reason: 'Artifact too large' };
}
return { allowed: true };
},
async afterPut(context) {
await auditLog('storage.put', context);
},
},
});
handleRequest() also accepts an actor object. That extra context flows into storage hooks and upstream resolution, which is great for multi-tenant routing, org-aware policy checks, and audit trails.
await registry.handleRequest({
method: 'GET',
path: '/npm/@acme/internal-lib',
headers: { authorization: `Bearer ${npmToken}` },
query: {},
actor: {
orgId: 'acme',
sessionId: 'sess_123',
},
});
Client Cheatsheet
npm
registry=https://registry.example.com/npm/
//registry.example.com/npm/:_authToken=<npm-token>
The npm handler also implements the npm-compatible login and token endpoints, including PUT /-/user/org.couchdb.user:<name> and /-/npm/v1/tokens.
Docker / OCI
Set oci.basePath to /v2 and expose a token endpoint that returns token, access_token, and expires_in.
if (url.pathname === '/v2/token') {
const token = await registry.getAuthManager().createOciToken(
'docker-user',
['oci:repository:*:*'],
3600,
);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
token,
access_token: token,
expires_in: 3600,
}));
return;
}
docker login registry.example.com
docker push registry.example.com/myorg/myimage:latest
docker pull registry.example.com/myorg/myimage:latest
Maven
<settings>
<servers>
<server>
<id>smartregistry</id>
<username>token</username>
<password>YOUR_MAVEN_TOKEN</password>
</server>
</servers>
</settings>
Point repositories or distribution management at https://registry.example.com/maven.
Cargo
# .cargo/config.toml
[registries.smartregistry]
index = "sparse+https://registry.example.com/cargo/"
# .cargo/credentials.toml
[registries.smartregistry]
token = "YOUR_CARGO_TOKEN"
Cargo sends the token as a plain Authorization header, not Bearer <token>.
Composer
{
"repositories": [
{
"type": "composer",
"url": "https://registry.example.com/composer"
}
]
}
{
"http-basic": {
"registry.example.com": {
"username": "my-user",
"password": "my-password"
}
}
}
Composer installs can use Basic auth if your authProvider.authenticate() supports it. Programmatic writes use Bearer tokens cleanly.
Composer package uploads accept materialized ZIP bodies up to 256 MiB. An archive is accepted only when it contains exactly one eligible manifest: composer.json at the root or <non-empty-segment>/composer.json. SmartRegistry validates the ZIP structure, extracts only that manifest, and limits its compressed size, uncompressed size, and total extracted output to 2 MiB each.
Transport adapters that expose Composer uploads must enforce COMPOSER_ZIP_LIMITS.maxArchiveBytes while reading the request body. ComposerRegistry applies the same limit again before copying, extracting, or storing the materialized body.
Malformed, ambiguous, over-limit, and sequential duplicate uploads are rejected before ZIP or metadata writes. Concurrent uploads of the same version are not serialized.
PyPI
[distutils]
index-servers = smartregistry
[smartregistry]
repository = https://registry.example.com/pypi
username = __token__
password = YOUR_PYPI_TOKEN
pip install --index-url https://registry.example.com/simple/ your-package
twine upload --repository smartregistry dist/*
RubyGems
:rubygems_api_key: YOUR_RUBYGEMS_TOKEN
gem push your-gem-1.0.0.gem --host https://registry.example.com/rubygems
bundle config set --global https://registry.example.com/rubygems YOUR_RUBYGEMS_TOKEN
Testing and Compatibility
The repository contains protocol-level tests, cross-protocol integration tests, upstream provider tests, storage hook tests, and native-client test suites that exercise the library through real ecosystem tooling.
Native-client coverage exists for:
- Docker / OCI
- npm
- Maven (
mvn) - Cargo
- Composer
- PyPI (
pipandtwine) - RubyGems (
gem)
There are also integration tests for S3-compatible storage and IS3Descriptor-based configuration.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the LICENSE file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.