@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, asks your auth provider about every operation, stores artifacts in S3-compatible object storage, 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.
  • One authorization question for every operation of every protocol, reads included: your auth provider decides, the registry has no bypass of its own.
  • Optional upstream proxying with retries, stale caching, negative caching, and circuit breakers.
  • Clean public API: handleRequest() in, IResponse out.

What It Actually Ships

Protocol Paths What works Auth style
OCI /oci/* or /v2/* version check, catalog, blobs, cross-repository mounts, manifests, tags, referrers, deletes Bearer JWT
npm /npm/* publish, packuments, version metadata, tarballs, dist-tags, search, unpublish, opt-in legacy login 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 or Basic auth with the token as password
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

Version 3.0.0 depends on @lossless.org/client ^2.3.0 (version 2.12.0 and later on ≥1.23.3), which is published only to Verdaccio while its MongoDB driver is the @tempfix/mongodb alias. Until the client returns to npmjs, these smartregistry versions publish only to Verdaccio as well; install them from there:

pnpm config set @tempfix:registry https://verdaccio.lossless.digital --location=project
pnpm config set @lossless.org:registry https://verdaccio.lossless.digital --location=project
pnpm config set @push.rocks:registry https://verdaccio.lossless.digital --location=project
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' },
  // registryUrl is required: every tarball URL a client receives is rendered from it
  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. It refuses every anonymous operation and allows a token what its scopes (<protocol>:<type>:<name>:<action>) cover. That is perfect for tests and local dev, but you will usually want a real IAuthProvider in production.

DefaultAuthProvider signs OCI tokens through OciTokenService 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, and you hand the request body over unread: the registry reads it only after the operation is authorized, never beyond the limit of the route, and streams OCI blob uploads to storage.

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 hasBody = req.method !== 'GET' && req.method !== 'HEAD';

  const response = await registry.handleRequest({
    method: req.method ?? 'GET',
    path: url.pathname,
    query: Object.fromEntries(url.searchParams),
    headers: headersToRecord(req.headers),
    body: hasBody ? Readable.toWeb(req) as ReadableStream<Uint8Array> : undefined,
    // the identity your application established, e.g. the org the request is for
    actor: { orgId: 'acme' },
  });

  res.writeHead(response.status, response.headers);

  if (!response.body) {
    res.end();
    return;
  }

  Readable.fromWeb(response.body).pipe(res);
}).listen(3000);

The registry reads exactly the bytes it needs: OCI digests are computed over the bytes the client sent, PyPI multipart/form-data uploads are parsed by the registry itself, and a body a route does not read (a refused request, a route without a body) is left unread for your HTTP server to discard. Do not cancel it from the outside either: Node's Readable.toWeb() destroys the socket on cancel, which would cut the response off.

Body limits (IRegistryConfig.limits) are enforced before anything beyond the limit is buffered, and a declared Content-Length above the limit is refused before anything is read. Every limit defaults and can be set on its own:

Limit Default Applies to
npmPublishBytes 100 MiB npm publish documents (JSON carrying the base64 tarball)
ociManifestBytes 4 MiB OCI manifest pushes, the size the OCI distribution spec asks registries to accept
ociBlobBytes 10 GiB one OCI blob upload, summed over all its requests
jsonBytes 1 MiB every other JSON body (dist-tags, login)
uploadBytes.<protocol> 100 MiB each one Maven, Cargo, Composer, PyPI or RubyGems artifact

A refused body answers 413 Payload Too Large in the protocol's error format.

At the public API boundary, response.body is always a ReadableStream<Uint8Array>, and every response carries X-Content-Type-Options: nosniff.

Authorization

The registry asks IAuthProvider.authorize(request) before it touches storage for any operation of any protocol, reads included. The request names everything the decision needs:

interface IRegistryAccessRequest {
  protocol: TRegistryProtocol;           // 'npm', 'oci', ...
  action: 'read' | 'write' | 'delete';
  operation: TRegistryOperation;         // 'npm.tarball.read', 'oci.blob.mountsource', 'pypi.write', ...
  resource: string;                      // npm package name, OCI repository, Maven 'groupId:artifactId', ...
  token: IAuthToken | null;              // null for an anonymous request
  actor: IRequestActor;                  // your context.actor, completed with the token's user
}

Tenancy is the namespace: an npm scope or the first segment of an OCI repository belongs to one tenant, and your provider maps the resource to its owner. Storage keys carry no tenant prefix. Anonymous access is your provider's decision per resource; no protocol has a bypass of its own.

Protocol Operations
npm npm.packument.read, npm.version.read, npm.tarball.read, npm.disttags.read, npm.search.read (per result), npm.publish, npm.disttags.write, npm.version.unpublish, npm.package.unpublish
OCI oci.manifest.read/write/delete, oci.blob.read, oci.blob.upload, oci.blob.delete, oci.blob.mountsource, oci.tags.read, oci.referrers.read, oci.catalog.read (per repository), oci.repository.delete
Maven, Cargo, Composer, PyPI, RubyGems <protocol>.read, <protocol>.write, <protocol>.delete; aggregate indexes (Composer packages.json, the PyPI Simple root, the RubyGems versions/names/specs files, Cargo search) list only what the requester may read

A refused npm read answers 404, so a package the requester may not read is not disclosed; a refused npm write answers 401 anonymously and 403 authenticated. A refused OCI operation answers 401 with the WWW-Authenticate challenge naming the scope (DENIED for an authenticated requester). The other protocols answer 401 with their challenge anonymously and 403 authenticated. Where a publish names its resource only inside its body (Cargo, PyPI, RubyGems), an anonymous request is refused before the body is read and the named resource is authorized once the bounded body is parsed.

OCI upload sessions are bound to the user that opened them and to their repository, identified by CSPRNG ids, capped per user (oci.uploadSessions.maxPerActor, default 32) and forgotten after oci.uploadSessions.ttlMs (default 1 hour) of inactivity. A blob is readable only through a repository that links it: an upload, a cross-repository mount (which needs read access to the source repository and the source's link) or a pull-through links it, DELETE of a blob removes only the repository's link, and a manifest may only reference blobs its own repository holds.

OciTokenService mints and validates the registry's OCI bearer tokens, so a custom provider can back its own /v2/token endpoint with short-lived, scoped tokens:

import { OciTokenService } from '@push.rocks/smartregistry';

const tokens = new OciTokenService({ jwtSecret, realm: `${publicUrl}/v2/token`, service: 'smartregistry' });

// in the token endpoint: decide which of the requested scopes the user gets
const requested = OciTokenService.parseScopes(url.searchParams.getAll('scope'));
const granted = requested.filter((access) => mayAccess(user, access));
const token = tokens.mint({ userId: user.id, access: granted, expiresInSeconds: 300 });
res.end(JSON.stringify(tokens.createTokenResponse(token, 300)));

// in IAuthProvider.validateToken(token, 'oci'):
return tokens.validate(token);

Operating Constraints

  • One writing instance per bucket. Updates of shared documents (packuments, OCI tags, Maven, Cargo, Composer, PyPI and RubyGems metadata) are serialized per document within the process (RegistryStorage.withKeyLock()), not across processes. Two instances writing the same bucket can lose updates.
  • OCI upload sessions live in memory. A registry serving OCI pushes runs as one instance, or routes every request of an upload to the instance that opened it. After a restart a client receives BLOB_UPLOAD_UNKNOWN and starts the upload again.

OCI Blob Storage and the 2.x Migration

A pushed or pulled-through blob is hashed as it arrives and stored as segments of at most oci.blobSegmentBytes (default and maximum 8 MiB, minimum 64 KiB). Each segment is one PutObject, well inside the object storage client's 60 s request timeout, and an upload holds at most one segment in memory, so a 10 GiB layer needs no more memory than a small one. The segment keys belong to the upload's generation, a CSPRNG id, never to the digest. The generation's record (digest, size, the segments with their offsets) is written only after every segment is stored and the digest and size are verified; the repository link, with its createdAt, follows. Every upload writes a generation of its own, also when the blob is already stored. One generation serves a blob: the one whose record key sorts first, as readers and garbage collection both choose it; garbage collection deletes the others once they and the serving one are older than its grace period. A range read opens only the segments the range covers.

3.x reads blobs only through generation records and repository links and never falls back to the keys 2.x wrote. Migrate a 2.x store once, with every 2.x writer stopped, before init():

const registry = new SmartRegistry(config);
const report = await registry.migrateStorage();
// { alreadyMigrated, blobRecords, blobLinks, manifestRecords, unreadableManifests }
await registry.init();

migrateStorage() writes a single-segment record for every 2.x blob that points at the blob's existing key, links every repository to the blobs its manifests reference, and records every manifest with its media type. It copies and deletes nothing, can be run again after an interruption, and once its marker is written answers alreadyMigrated: true at once. init() refuses an unmigrated 2.x store with RegistryMigrationRequiredError; a store that starts empty under 3.x needs no migration. 2.x blobs that no manifest referenced get no link: the first garbage collection after the grace period deletes them, through their records, like every blob that becomes unreferenced later. Never let a 2.x registry write the store after the migration.

Garbage Collection Belongs to smartregistry

Only smartregistry may delete what it stores. Its storage model (blob generations made of segments, per-repository links, npm tombstones, upload segments) is not a set of independent files: a consumer that sweeps keys below oci/ or npm/ by its own reachability rules deletes data that is still live.

  • Delete through the registry: npmRegistry.deleteVersion(), deletePackage(), ociRegistry.deleteManifest(), deleteRepository() (or the protocols' HTTP deletes). Each asks the auth provider and leaves a consistent state.

  • Decide retention with ociRegistry.listManifests(repository), which lists every manifest with its digest, mediaType, size, pushedAt, pushedBy and the tags that point at it. Delete what your retention drops with deleteManifest(), or let pruneRepository(repository, { graceMs }) delete every manifest no tag reaches (directly, through an index, or as a referrer of a reachable subject) that was pushed longer ago than the grace period (default: the garbage collection grace period, at least 1 hour; pushing a manifest again renews its push time). pruneAllRepositories({ graceMs }) does so for every tagged repository. A prune finds its candidates first, then reads the tags and manifests again under the repository's lock, which a manifest push holds while it checks its references, and deletes only what is still unreachable and old: an index pushed meanwhile keeps its children, and one pushed after them answers 400 MANIFEST_BLOB_UNKNOWN, so no index ever names a manifest that is gone. Neither deletes blobs: they leave the links to garbage collection.

  • Then reclaim storage with await ociRegistry.collectGarbage({ graceMs, signal }). It runs only when you call it (there is no timer), one pass at a time, and touches nothing younger than the grace period: graceMs, else oci.garbageCollection.graceMs, else 7 days. A grace period below 1 hour is refused with a RangeError; choose one longer than your backup interval. A pass

    1. drops the links of a repository that none of its manifests references,
    2. drops reverse links whose link is gone,
    3. deletes every generation of a blob no repository links (the links are listed again right before): its record first, its segments after; for a linked blob whose serving generation is older than the grace period, it deletes the other generations older than the grace period the same way, migrated 2.x records included. A single blob read that lasts longer than the grace period can therefore lose its segments,
    4. deletes the segments of uploads nothing owns once they are older than the upload session lifetime plus the grace period, and the chunks 2.x uploads left under oci/uploads/.

    It answers what it removed (unlinkedBlobs, deletedReverseLinks, deletedBlobGenerations, deletedRedundantGenerations, deletedAbandonedSegments, deletedLegacyUploadChunks) and failedRepositories: a repository whose manifests cannot all be read keeps every link. Within the process, writing and removing links, mounts and the collection of a blob are serialized per blob, and manifest pushes and the collection of a repository's links per repository, so a pass never deletes what a concurrent push or mount relies on.

  • A consumer that runs its own garbage collection deletes it in the same change that moves to 3.x.

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) the handler of an enabled protocol, typed by protocol (getRegistry('npm') is an NpmRegistry)
npm.listVersions(name) published versions with publish time, publisher and dist-tags
npm.deleteVersion(name, version, access) / npm.deletePackage(name, access) unpublish, leaving tombstones; asks the auth provider
oci.deleteManifest(repository, digest, access) / oci.deleteRepository(repository, access) delete manifests (and their tags) or a whole repository (manifests, tags, blob links); asks the auth provider
oci.listManifests(repository) every manifest of a repository with digest, mediaType, size, pushedAt, pushedBy and its tags, for retention
oci.pruneRepository(repository, { graceMs }) / oci.pruneAllRepositories({ graceMs }) delete the manifests no tag reaches that are older than the grace period; blob links are left to garbage collection
await oci.collectGarbage({ graceMs, signal }) reclaim what no repository holds (see Garbage Collection Belongs to smartregistry)
await registry.migrateStorage() migrate a store smartregistry 2.x wrote, once, before init()
await registry.close() release timers, protocol resources and the object storage client

The delete APIs take the same token and actor a request would carry ({ token?, actor }) and answer 'deleted', 'not-found' or 'refused'.

Shut a registry down with await registry.close(). It is idempotent, and it closes the object storage client that RegistryStorage opened in init(). 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.

RegistryStorage reads resolve null only for an object that does not exist: getObject(), getObjectStream(), getObjectSize() and every protocol reader built on them reject when the object storage fails or denies the read. Registries write documents back after reading them (OCI tags.json, npm packuments, Cargo indexes, metadata files), so an unreadable document must never pass for a missing one; such a request fails instead of overwriting what is stored. storage.getObjectRangeStream(key, { start, end }) streams one inclusive byte range with a single ranged read and reports the object's total size. storage.listObjectEntriesPage({ prefix, limit, startAfter }) lists one keyset page (1 to 1000 entries) of { key, size, lastModified } without a request per object; pass nextStartAfter as the next startAfter. Paging needs an object storage that honours ListObjectsV2 start-after.

The package also exports the protocol-specific registry classes, upstream classes, RegistryStorage, RegistryStorageClosedError, RegistryRequestBodyError, RegistryMigrationRequiredError, AuthManager, DefaultAuthProvider, OciTokenService, 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
limits request body limits (see HTTP Integration)
oci / npm / maven / cargo / composer / pypi / rubygems enable protocols, set base paths, and define the public registry URL they should emit
npm.registryUrl required: the one public base URL tarball URLs are rendered from, never derived from a request's Host
npm.legacyLogin { tokenExpiresInSeconds, readonly? } serves npm login (PUT /-/user/org.couchdb.user:<name>): credentials verified by the provider, minted tokens expire; not served unless set
oci.uploadSessions { ttlMs?, maxPerActor? } for in-memory upload sessions
oci.blobSegmentBytes size of one stored blob segment, 64 KiB to 8 MiB (default 8 MiB)
oci.garbageCollection { graceMs? }: the default grace period of collectGarbage(), at least 1 hour (default 7 days)

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.

OCI pull-throughs go through the same ingest as a push. A blob streams to the client while it is hashed and stored in segments, under the blob limit; it is recorded and linked into the repository only when its bytes match the requested digest, and a mismatch fails the response. A client that stops reading discards what was stored. An upstream blob that declares a length above the blob limit is not fetched. A manifest is stored as the exact bytes the upstream sent, read up to the manifest limit and verified against the requested digest and the upstream's Docker-Content-Digest, so its digest never changes.

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. It reaches every authorization question, storage hook and upstream resolution, which is how a multi-tenant application passes the org a request is for. Fields you set win over what the registry derives from headers (X-Forwarded-For, User-Agent); the token's user is always the actor's userId.

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>

Your application owns token issuance: the npm token management endpoints (/-/npm/v1/tokens) are not served, and npm login is served only with npm.legacyLogin configured.

Publishing is validated: one version per publish, name and version consistent between the URL, the document and its version, one tarball attachment named <name>-<version>.tgz whose length and declared dist.shasum/dist.integrity match its bytes, dist-tags that point at the published version, and npm's naming rules. A version that exists, or was ever unpublished, is never published again: an unpublished version keeps its time entry as a tombstone, and a package unpublished whole keeps its packument as a tombstone (reads answer 404). Tarball URLs are stored relative and rendered from npm.registryUrl on every read, so 2.x documents with absolute URLs render against the configured URL as well.

Docker / OCI

Set oci.basePath to /v2 and expose a token endpoint that returns token, access_token, and expires_in; OciTokenService (see Authorization) mints scoped, short-lived tokens for it.

docker login registry.example.com
docker push registry.example.com/myorg/myimage:latest
docker pull registry.example.com/myorg/myimage:latest

A manifest push is refused with 400 MANIFEST_BLOB_UNKNOWN (one error per missing digest, named in detail.digest) when its config or a layer blob has not been uploaded, or when an index names a child manifest the repository does not hold, so push children and blobs before the manifest that references them. subject may name a manifest that does not exist yet, and non-distributable layers (a urls list, or a nondistributable/foreign media type) are not checked. A body that is not a JSON manifest with string digests is refused with 400 MANIFEST_INVALID.

Blob GETs honour one HTTP byte range (RFC 9110), so a resumed pull, such as containerd's Range: bytes=<offset>-, receives only the rest of a layer: start-end, start- and the suffix -length answer 206 Partial Content with Content-Range: bytes start-end/size, and only those bytes are read from the object storage. Docker-Content-Digest is the digest of the whole blob on every answer. A range that starts at or beyond the end of the blob, or a zero-length suffix, answers 416 with Content-Range: bytes */size. Several ranges, another unit or invalid syntax are ignored and the whole blob answers 200. Blob GET and HEAD answer Accept-Ranges: bytes. A ranged GET of a blob that is not stored yet pulls the whole blob from the upstream first, then serves the range from storage.

Manifests are accepted with the OCI image manifest and index and the Docker v2 manifest and manifest list media types only; the Content-Type must match a mediaType the manifest names, and a stored type outside the list is never replayed. A push by digest must match the digest of the body. Repository names, tags and digests (sha256 only) are validated as the spec defines them. GET /v2/_catalog and the tag list page with n and last.

Chunked uploads (PATCH with Content-Range, which must continue the upload or the request answers 416) and monolithic uploads (POST or PUT with digest) are both supported. Upload responses announce OCI-Chunk-Min-Length, the segment size: every chunk but the last must carry at least that many bytes. Once a PATCH stored fewer, further data in the session (another PATCH, or a PUT with a body) answers 416 with OCI-Chunk-Min-Length and the current Range; the session can still be completed with an empty PUT or cancelled. Chunks are never merged, so a blob is stored in at most twice the segments its size needs; DELETE cancels an upload and deletes its segments. Upload sessions live in memory (see Operating Constraints). The segments of an upload whose session expired or whose process stopped stay until collectGarbage() finds them older than the session lifetime plus the grace period; nothing is collected on a timer.

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>. config.json declares auth-required, so Cargo sends the token with every request; it needs a credential provider for that:

# .cargo/config.toml
[registry]
global-credential-providers = ["cargo:token"]

Composer

{
  "repositories": [
    {
      "type": "composer",
      "url": "https://registry.example.com/composer"
    }
  ]
}
{
  "http-basic": {
    "registry.example.com": {
      "username": "token",
      "password": "YOUR_COMPOSER_TOKEN"
    }
  }
}

Composer sends the token as the Basic auth password; programmatic calls may use a Bearer token.

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.

The registry reads the upload body itself, bounded by the smaller of limits.uploadBytes.composer and COMPOSER_ZIP_LIMITS.maxArchiveBytes.

Malformed, ambiguous, over-limit, and duplicate uploads are rejected before ZIP or metadata writes; uploads of one package are serialized within the process.

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

Every upload is stored with its SHA-256 and BLAKE2b-256 digests (and its MD5 when the client declares one); a declared sha256_digest, blake2_256_digest or md5_digest that does not match the file is refused with 400. The JSON API serves them as digests under PyPI's names (sha256, blake2b_256, md5); the Simple API (the HTML #sha256= fragment and the PEP 691 hashes object) names SHA-256 only.

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 (pip and twine)
  • RubyGems (gem)

There are also integration tests for S3-compatible storage and IS3Descriptor-based configuration. The native-client suites listen on free ports and log in for every read, as a production deployment requires.

The native-client tools are prerequisites of the test suite. A test whose tool is missing fails and names what to install; none is skipped:

  • Docker CLI with a running daemon on unix:///var/run/docker.sock
  • npm (ships with Node.js)
  • Rust cargo 1.74 or later
  • Composer 2 with PHP
  • Apache Maven 3 with a JDK
  • RubyGems gem with Ruby
  • pip for Python 3 and twine

The suite also needs the object storage service the repository's test configuration names (the S3_* variables of .nogit/env.json).

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.

S
Description
a module implementing package registries for oci, npm, maven
Readme
2.2 MiB
Languages
TypeScript 100%