@push.rocks/smartregistry
One TypeScript registry core for OCI, npm, Maven, Cargo, Composer, PyPI, RubyGems, Alpine, and Debian.
@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, nine 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,IResponseout.
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 |
| Alpine | /alpine/* |
signed APKINDEX.tar.gz per branch, repository and architecture, apk v2 upload, download, delete, public key |
Basic auth with the token as password (https://user:<token>@host/… repository URLs), or Bearer |
| Debian | /debian/* |
signed InRelease, Release and Release.gpg per distribution, Packages indexes with by-hash copies per component and architecture, .deb upload, download, delete, public key |
Basic auth with the token as password (/etc/apt/auth.conf.d/), or Bearer |
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
Versions 3.2.0 and later need Node.js ^24.20.0 || >=26.6.0 (engines), the range of its Linux package codec @push.rocks/smartlinuxrepo: those releases report a zstd frame that ends early as an error. pnpm only warns on a mismatch.
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) and the packument rewrite of npm unpublish <name>@<version> |
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, RubyGems, Alpine or Debian artifact |
linuxStateDocumentBytes |
64 MiB | the state document of one Linux package repository (an Alpine branch and repository, a Debian distribution); a publish or delete that would write a larger one answers 507 Insufficient Storage and writes nothing |
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 |
| Alpine | alpine.read, alpine.write, alpine.delete on the repository namespace/branch/repository (indexes, package files, uploads, deletes); alpine.read on the namespace for its public key |
| Debian | debian.read, debian.write, debian.delete on the distribution namespace/distribution (index files, package files, uploads, deletes of every component); debian.read on the namespace for its public key |
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, Alpine and Debian repository states) 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_UNKNOWNand 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 itsdigest,mediaType,size,pushedAt,pushedByand thetagsthat point at it. Delete what your retention drops withdeleteManifest(), or letpruneRepository(repository, { graceMs })delete every manifest no tag reaches (directly, through an index, or as a referrer of a reachablesubject) 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 answers400 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, elseoci.garbageCollection.graceMs, else 7 days. A grace period below 1 hour is refused with aRangeError; choose one longer than your backup interval. A pass- drops the links of a repository that none of its manifests references,
- drops reverse links whose link is gone,
- 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,
- 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) andfailedRepositories: 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.
Alpine Repositories
Alpine (apk) repositories are served at Gitea 28.2.0's paths below {basePath}/{namespace}/, where the namespace is Gitea's owner (your tenant). Every repository is signed with the one key you configure; there are no unsigned repositories.
import { createAlpineSigner, giteaAlpineKeyName } from '@push.rocks/smartlinuxrepo';
// the key pair your secret store holds: smartregistry never generates, stores or logs keys
const privateKeyPem = await secrets.get('alpine-private-key'); // PKCS#1 or PKCS#8 RSA PEM
const publicKeyPem = await secrets.get('alpine-public-key'); // PKIX "PUBLIC KEY" PEM
const keyName = giteaAlpineKeyName('registry-example-com', publicKeyPem); // <owner>@<sha256>.rsa.pub
const registry = new SmartRegistry({
...config,
alpine: {
enabled: true,
basePath: '/alpine',
signing: { signer: createAlpineSigner({ privateKeyPem, keyName }), publicKeyPem },
},
});
generateAlpineKeyPair() of @push.rocks/smartlinuxrepo makes an RSA-4096 pair in Gitea's forms. init() refuses to start unless publicKeyPem is a PKIX RSA public key, the signer's keyName is a file name, and a probe the signer signs verifies against publicKeyPem (RSA PKCS#1 v1.5, SHA-1), so a signer that does not match its key never reaches a client. The key name is what apk looks the key up by in /etc/apk/keys; the index signature entry is .SIGN.RSA.<keyName>.
| Route | Operation | Answers |
|---|---|---|
GET {ns}/key |
alpine.read on {ns} |
200 with the PEM, Content-Disposition: attachment; filename="<keyName>" |
PUT {ns}/{branch}/{repository} (the raw .apk as body) |
alpine.write |
201; 400 for a body that is not an apk v2 package (the codec's error code in the body) or an architecture that cannot name an index; 409 when the file exists; 413 over limits.uploadBytes.alpine; 507 over limits.linuxStateDocumentBytes |
HEAD, GET {ns}/{branch}/{repository}/{arch}/APKINDEX.tar.gz |
alpine.read |
200, else the noarch index, else 404 |
GET {ns}/{branch}/{repository}/{arch}/{file} |
alpine.read |
200, else the file under noarch, else 404 |
DELETE {ns}/{branch}/{repository}/{arch}/{file} |
alpine.delete |
204; 404 unless the file exists under exactly this architecture |
The operations of a repository are authorized on {ns}/{branch}/{repository}. A refused anonymous request answers 401 with WWW-Authenticate: Basic realm="<basePath>", a refused authenticated one 403; error bodies are plain text, as Gitea's. Path segments are decoded and trimmed; a namespace, branch or repository that is empty, . or .., or holds a / (%2F too), whitespace or a control character answers 400. The base path matches whole segments only: /alpine never serves /alpinefoo.
Packages are apk v2, signed or unsigned, stored as uploaded; apk v3 (ADB) packages are refused, as Gitea refuses them. A branch and repository holds one file per architecture and file name (<name>-<version>.apk), and a deleted file can be uploaded again. noarch packages are listed in every architecture's index under that architecture (A:), so apk asks for them under its own architecture: package files and indexes missing there are served from noarch, and a repository of noarch packages only answers every architecture with its noarch index.
Indexes are built and signed when a package is published or deleted, never on read. Each branch and repository has one state document: the package records exactly as @push.rocks/smartlinuxrepo returned them, the index files written and the files waiting for deletion. Storage keys are computed from the namespace, branch, repository, architecture and file name, never stored. A publish or delete runs under the repository's lock:
- every decision comes first:
400,404,409,507, and rendering and signing the new indexes (a failure answers500); a refused request writes nothing. Once a request passed them, the repository's index prefix is listed for orphans (below), whose entries count towards the state limit too; - files a previous commit left for deletion and now due are deleted;
- the package file of an upload is stored, then the index files;
- the state document is written: the commit point;
- a deleted package file, the index of an architecture whose last package went and every orphan are deleted at once (a failed deletion is logged and the next commit deletes it);
- the artifact hook is called.
A commit that stops before the state document leaves the previous state in force and a retry that succeeds, never a stuck 409. The index files it already wrote stay served until that repository's next commit: they can list the package it was publishing. That commit renders again every index its state names, and deletes every orphan, an index file under the repository's prefix that neither its state nor the commit names (such as the index of an architecture the interrupted upload added, which would otherwise hide the noarch fallback for that architecture for good). The same accepted risk applies to a deletion after the commit point that fails: the next commit makes it.
Package files are served straight from their computed keys and are not collected. A file an interrupted upload stored stays readable by readers of that repository at its key; once the orphan index is gone no index names it, and a retry of the upload overwrites it.
Until then that leftover {arch}/{file} is served for its architecture and file name instead of the noarch fallback, and apk refuses it because it does not match the checksum in the signed index. Both orders hit this: a noarch package committed after the interrupted upload of the same file name, and an upload of a live noarch package's file name for one architecture that was interrupted. To recover, upload that architecture's file again (the state does not record it, so there is no 409), then DELETE {branch}/{repository}/{arch}/{file}: the delete removes exactly that architecture's key, and the noarch fallback works again.
Rotating the Alpine key
- Generate the new key pair.
- Hand the new public key to your clients under its key name. apk trusts every file in
/etc/apk/keys, so the old and the new key sit side by side. - Change
alpine.signingto the new key and restart. - Call
await registry.getRegistry('alpine')!.resign(). It commits every repository again under its lock with its packages unchanged, as a publish without a package file: theAPKINDEX.tar.gzfiles are built and signed with the configured key, then the state document is written. It emits no artifact event and answers{ repositories }, the number of repositories it signed; a repository without packages has no index and is skipped.
GET key serves the new key from the restart on. Remove the old key from clients once resign() finished. If resign() fails partway (a storage or signing failure, or a state document that would exceed limits.linuxStateDocumentBytes, which the error names), the repositories it already signed keep the new signature and the others the old one, which clients still trust; no package changes, so run it again until it succeeds.
Debian Repositories
Debian (apt) repositories are served at Gitea 28.2.0's paths below {basePath}/{namespace}/ (default base path /debian), where the namespace is Gitea's owner (your tenant). A namespace holds distributions, a distribution holds components. Every distribution is signed with the one OpenPGP key you configure; there are no unsigned repositories.
import { createOpenPgpSigner } from '@push.rocks/smartlinuxrepo';
// the key pair your secret store holds: smartregistry never generates, stores or logs keys
const privateKeyArmored = await secrets.get('openpgp-private-key'); // unencrypted OpenPGP v4 key
const publicKeyArmored = await secrets.get('openpgp-public-key'); // exactly one PUBLIC KEY BLOCK
const registry = new SmartRegistry({
...config,
debian: {
enabled: true,
basePath: '/debian',
signing: { signer: await createOpenPgpSigner({ privateKeyArmored }), publicKeyArmored },
origin: 'registry.example.com',
label: 'registry.example.com',
},
});
generateOpenPgpKeyPair() of @push.rocks/smartlinuxrepo makes an RSA-4096 OpenPGP v4 pair. init() refuses to start unless publicKeyArmored is exactly one OpenPGP v4 public key block (a private key block is refused: repository.key serves it to every reader), a probe the signer signs verifies against it, and origin and label are each one line, non-empty and without surrounding whitespace. Any IOpenPgpSigner works (an HSM, a remote signer). The signing configuration (ILinuxOpenPgpSigningConfig) is the one the RPM and Arch formats will take; one key may serve several of them.
Choose origin and label once. They go into every Release, and apt refuses to update from a repository whose Origin or Label changed until each client confirms the change (apt-get update --allow-releaseinfo-change, apt-get(8)). Gitea writes its application name into both.
| Route | Operation | Answers |
|---|---|---|
GET {ns}/repository.key |
debian.read on {ns} |
200 with the armored key, application/pgp-keys, Content-Disposition: attachment; filename="repository.key" |
PUT {ns}/pool/{distribution}/{component}/upload (the raw .deb as body) |
debian.write on {ns}/{distribution} |
201; 400 for an invalid distribution or component, a body that is not a Debian binary package (the codec's error code in the body), a version holding / or ?, or an architecture that cannot name an index path or pool file; 409 when the component holds the file; 413 over limits.uploadBytes.debian; 507 over limits.linuxStateDocumentBytes |
GET {ns}/dists/{distribution}/InRelease, Release, Release.gpg |
debian.read |
200, else 404 |
GET {ns}/dists/{distribution}/{component}/binary-{arch}/Packages, Packages.gz, Packages.xz and their by-hash/{MD5Sum,SHA1,SHA256,SHA512}/{hex} |
debian.read |
200, else 404 |
GET {ns}/pool/{distribution}/{component}/{name}_{version}_{arch}.deb |
debian.read |
200 (application/vnd.debian.binary-package), else 404 |
DELETE {ns}/pool/{distribution}/{component}/{name}/{version}/{arch} |
debian.delete |
204; 404 unless the component holds that file |
Distributions and components follow Gitea's rule ^[a-zA-Z0-9][a-zA-Z0-9.~+_-]*$; an invalid one answers 400 to an upload and 404 to a read or delete. Index files are served at exactly the paths @push.rocks/smartlinuxrepo renders, case-sensitive: another spelling of a hash algorithm, a path without binary- or dists/{distribution}/by-hash/…, which Gitea also answers, answer 404; apt requests only the paths InRelease names. Each route serves the one method Gitea registers for it; none answers HEAD. A refused anonymous request answers 401 with WWW-Authenticate: Basic realm="<basePath>", a refused authenticated one 403; error bodies are plain text. A namespace segment is checked as Alpine's are (400), and the base path matches whole segments only.
A distribution holds one file per component and file name (<name>_<version>_<arch>.deb, the epoch included); the same file can be uploaded to several components, and a deleted file can be uploaded again. Release lists every component and architecture of the distribution; all packages are listed only in binary-all, and Architectures names all like any other architecture. Control archives may be plain, gzip, xz or zstd.
Index files are built and signed when a package is published or deleted, never on read, by the commit Alpine uses (see Alpine Repositories): one state document per distribution holds every entry as the codec takes it ({ component, record }), the Packages files and their by-hash copies are written before Release, Release.gpg and InRelease, and the state document last. Because InRelease names every Packages file by hash, a client that read the previous InRelease keeps finding the by-hash files it names: a replaced by-hash file, and one a commit that stopped before its state document left, stays for an hour after the commit that replaced or found it and is deleted by the first commit after that hour. The canonical Packages paths are overwritten in place. When a distribution's last package goes, that commit deletes its Release files, its Packages files and every file waiting for deletion: no InRelease names them any more.
Rotating the Debian key
- Generate the new key pair.
- Hand the new public key to your clients next to the old one:
signed-bytakes a list of keyring files (comma-separated in the one-line format, sources.list(5)). - Change
debian.signingto the new key and restart. - Call
await registry.getRegistry('debian')!.resign(). It commits every distribution again under its lock with its packages unchanged, as a publish without a package file: thePackagesfiles, thenRelease,Release.gpgandInReleasesigned with the configured key, then the state document. APackagesfile whose bytes changed (another Node.js or codec version can compress differently) keeps its oldby-hashcopies for an hour. It emits no artifact event and answers{ repositories }, the number of distributions it signed; a distribution without packages has no index files and is skipped.
GET repository.key serves the new key from the restart on. Remove the old key from clients once resign() finished. If resign() fails partway, the distributions it already signed carry the new signature and the others the old one, which clients that hold both keys still trust; no package changes, so run it again until it succeeds.
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 alpine.resign() |
sign every Alpine repository's indexes again with the configured key, after a key rotation |
await debian.resign() |
sign every Debian distribution's index files again with the configured key, after a key rotation |
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, RegistryArtifactHookError, 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 for every stored object, with protocol and actor (npm also sets package and version) |
artifactHooks |
receive one event per committed publish or delete of a version or package (npm, OCI, Cargo, Composer, Alpine, Debian); see Artifact Hooks |
upstreamProvider |
decide per request which upstream registries to consult |
limits |
request body and Linux repository state limits (see HTTP Integration) |
oci / npm / maven / cargo / composer / pypi / rubygems / alpine / debian |
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) |
alpine.signing |
required: { signer, publicKeyPem }, the one key every Alpine repository is signed with (see Alpine Repositories) |
debian.signing |
required: { signer, publicKeyArmored }, the one OpenPGP key every Debian distribution is signed with (see Debian Repositories) |
debian.origin / debian.label |
required: the Origin and Label of every Release (see Debian Repositories) |
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);
},
},
});
Storage hooks fire once per stored object: an npm publish stores a tarball and a packument, an OCI push stores blob segments, records, links, a manifest and a tags document. Use them for per-object quota checks (beforePut); book packages and versions from artifact hooks.
Artifact Hooks
artifactHooks are called exactly once per committed operation that publishes or deletes a version or a package, with the artifact's identity:
const registry = new SmartRegistry({
...config,
artifactHooks: {
async afterVersionPublished(event) {
// { kind: 'version.published', protocol, name, version, digest, size, tags, created, actor, timestamp, reference?, mediaType?, repository?, architecture? }
await packages.addVersion(event.protocol, event.name, event.version, event.size, event.actor.orgId);
},
async afterVersionDeleted(event) {
// { kind: 'version.deleted', protocol, name, version, digest?, size, tags, actor, timestamp, mediaType?, repository?, architecture? }
await packages.removeVersion(event.protocol, event.name, event.version);
},
async afterPackageDeleted(event) {
// { kind: 'package.deleted', protocol, name, versions: [{ version, digest?, size, mediaType? }], actor, timestamp }
await packages.remove(event.protocol, event.name);
},
async onHookError(error) {
// error.hook, error.event, error.cause: the operation is committed, the hook did not record it
await repairQueue.push(error.event);
},
},
});
| Protocol | version.published |
version.deleted |
package.deleted |
|---|---|---|---|
| npm | a publish | an unpublish of one version (npm unpublish <name>@<version>, DELETE /<name>/-/<version>, deleteVersion()), one event per version a packument rewrite removes |
an unpublish of the package (DELETE /<name>/-rev/<rev>, deletePackage()) |
| OCI | a manifest push, by tag or by digest | a manifest delete (DELETE, deleteManifest()) and every manifest pruneRepository() deletes |
deleteRepository() |
| Cargo | a publish | ||
| Composer | an upload | a version delete | a package delete |
| Alpine | an upload of a package file | a delete of a package file | |
| Debian | an upload of a package file | a delete of a package file |
- When. After the operation's last write and before its response is answered (or its API call resolves), under the lock that serializes the package's operations (OCI: the repository's; Alpine: the branch and repository's; Debian: the distribution's), so the events of one package arrive one at a time in commit order. A hook that never settles holds that package's further operations. A hook may call the registry again while it runs; the locks are re-entrant for it, and work it leaves running after it settled waits for the locks like any other caller.
- Never for a refused, invalid, duplicate or missing operation, and never for writes the registry makes on its own behalf: upstream cache fills (npm tarballs, OCI pull-throughs),
migrateStorage()and Alpine's and Debian'sresign(). Aversion.deletedcan therefore name a version noversion.publishedannounced, one stored before the hooks were configured or by an upstream cache fill. What only an event needs (the size of a version about to be deleted) is read before the first write, so a failed read fails the operation before it commits. - Failures. A hook that rejects does not undo the operation and does not fail its request: the operation is committed and its response says so, since a client told otherwise would retry and be refused. The failure is logged at
errorlevel and passed toonHookError()as aRegistryArtifactHookErrorwith thehook, theeventand thecause; a rejectingonHookError()is logged too. - Identity.
nameis the npm package, OCI repository, Cargo crate, Composervendor/package, Alpine or Debian package name; Alpine and Debian events also carry therepository(Alpinenamespace/branch/repository, Debiannamespace/distribution: the resource the operation was authorized on) and the package file'sarchitecture, and Debian events thecomponent, so the same file in two components gives two distinct events.versionis the version; for OCI it is the manifest digest, andreferenceis the tag or digest the push named.tagsare the tags the operation pointed at the version (npm dist-tags of the publish, the OCI tag of a push by tag) or, on deletion, the tags removed with it; npm then pointslatestat the most recently published remaining version, whichlistVersions()reports; dist-tag changes (npm dist-tag) are no artifact events.createdisfalseonly for an OCI push of a manifest the repository already holds.actoris the request's actor (the token's user asuserId) or theactora delete API was called with; it is empty forpruneRepository(). - Digest (
<algorithm>:<hex>): npmsha512of the tarball (on deletion read fromdist.integrity, absent when it has no SHA-512 entry), OCI the manifest digest, Cargosha256of the.crate, Composersha1of the zip, Alpinesha256of the.apk, Debiansha256of the.deb. - Size (bytes): npm the tarball, Cargo the
.crate, Composer the zip, Alpine the.apk, Debian the.deb. OCI: the manifest's bytes plus the stored size of every distinct blob it names (config and layers; non-distributable layers, which are never uploaded, do not count). An index counts its own bytes only, since each child manifest is a version of its own. Summed over a package's versions this is its logical size: a layer several manifests share counts once per manifest. A deletion reports the size measured the same way, 0 for what was not stored.
PyPI, RubyGems and Maven emit no artifact events: a PyPI upload commits one file of a version (the same file name can be uploaded again) and its deletes answer success without checking that anything existed, a RubyGems push replaces a version that exists, and a Maven deploy is a series of file uploads with no commit of the version. Cargo and RubyGems yanks delete nothing and emit nothing.
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).
npm unpublish <name>@<version> works as npm sends it: a rewrite of the packument without the version (PUT /<name>/-rev/<rev>), then a delete of its tarball (DELETE /<name>/-/<file>.tgz/-rev/<rev>). Both are authorized as npm.version.unpublish. The rewrite is served only as an unpublish: it removes the stored versions the document no longer names, may name no version that is not stored, and must remove at least one (an npm owner or npm deprecate rewrite is refused with 400); nothing else of the document is taken, and the registry decides the dist-tags afterwards (latest moves to the most recently published remaining version). The tarball delete of a version the rewrite removed answers 200; one of a version still published unpublishes it. Revisions are not checked. npm unpublish <name> --force (and the last version) is DELETE /<name>/-rev/<rev>, the package unpublish.
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
Alpine (apk)
# import the key once, under the name the index signature names
wget -O /etc/apk/keys/<keyName> "https://apk:<token>@registry.example.com/alpine/<namespace>/key"
echo "https://apk:<token>@registry.example.com/alpine/<namespace>/v3.22/main" >> /etc/apk/repositories
apk update && apk add <package>
# publish and delete
curl --user apk:<token> --upload-file sr-tool-2.1.3-r1.apk https://registry.example.com/alpine/<namespace>/v3.22/main
curl --user apk:<token> -X DELETE https://registry.example.com/alpine/<namespace>/v3.22/main/x86_64/sr-tool-2.1.3-r1.apk
The token is the Basic auth password; the user name is not checked. apk-tools 2.14 and 3.0 and BusyBox wget send the credentials of the URL with the first request.
Debian (apt)
# the token is the password, the login is not checked; an entry without a protocol matches https only (apt_auth.conf(5))
printf 'machine registry.example.com/debian/<namespace>\nlogin apt\npassword <token>\n' > /etc/apt/auth.conf.d/registry.conf
chmod 600 /etc/apt/auth.conf.d/registry.conf
# fetch the key with those credentials and name it in signed-by
/usr/lib/apt/apt-helper download-file https://registry.example.com/debian/<namespace>/repository.key /tmp/repository.key
install -D -m 0644 /tmp/repository.key /etc/apt/keyrings/registry.asc
echo "deb [signed-by=/etc/apt/keyrings/registry.asc] https://registry.example.com/debian/<namespace> <distribution> main" > /etc/apt/sources.list.d/registry.list
apt-get update && apt-get install <package>
# publish and delete
curl --user apt:<token> --upload-file hello_1.0-1_amd64.deb https://registry.example.com/debian/<namespace>/pool/<distribution>/main/upload
curl --user apt:<token> -X DELETE https://registry.example.com/debian/<namespace>/pool/<distribution>/main/hello/1.0-1/amd64
apt 2.6, 2.8 and 3.0 send the auth.conf credentials with the first request (verified over plain http in the apt test). A plain-http repository needs the protocol in the entry (machine http://host:port/debian/<namespace>).
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) - Debian (
apt2.6 on Debian 12,apt3.0 with sqv on Debian 13 andapt2.8 on Ubuntu 24.04, each in a container capped at 2 CPUs and 2 GiB; the packages are built with dpkg-deb in a container without network) - Alpine (
apkof apk-tools 2.14 on Alpine 3.20 and 3.22 and apk-tools 3.0 on edge, each in a container capped at 2 CPUs and 2 GiB; the packages are built with abuild in a container that needs network to install it)
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
cargo1.74 or later - Composer 2 with PHP
- Apache Maven 3 with a JDK
- RubyGems
gemwith Ruby pipfor Python 3 andtwine
The suite also needs the object storage service the repository's test configuration names (the S3_* variables of .nogit/env.json).
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.