beforePut fails closed when the organization is missing or unknown, and the registry passes explicit smartregistry limits: 2 GiB per OCI blob, 64 MiB per npm package upload, 8 open OCI uploads per actor.
@stack.gallery/registry 📦
A self-hosted package registry for npm packages and OCI/Docker images, with organizations,
scoped API tokens, an audit trail and a web UI. It runs on Node.js, keeps its records in a
MongoDB-protocol database and its artifacts in S3-compatible object storage, and is distributed as a
container image (linux/amd64, linux/arm64). Single executables and their installer (install.sh) are
not available yet.
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.
✨ Features
- 🔌 npm and OCI through
@push.rocks/smartregistry: one npm endpoint for every organization, OCI repositories named<organization>/<repository> - 🏢 Organizations as namespaces: an npm scope belongs to exactly one organization, an organization's name is its OCI namespace, and names are permanent
- 🛡️ Resource-aware authorization decided on the server from database records: organization roles, teams, repository grants and token scopes
- 🎫 Scoped API tokens (
srg_…) for CI: stored hashed, limited to protocols, organizations, repositories and actions, expiring after at most a year - 🔐 Accounts created by platform admins, scrypt password hashes, rate limits and account lockout, optional sign-in with idp.global
- 📊 Audit log of sign-ins, account, organization and token changes and refused protocol writes
- 🩺 Real health:
/healthzanswers 200 only while the database and the object storage answer
🚀 Running the registry
Container
Dockerfile_##version## builds a multi-architecture image (linux/amd64, linux/arm64) on
code.foss.global/host.today/ht-docker-node:alpine-node. The image runs node cli.js server as the
unprivileged user 10001:10001, listens on port 3000 and declares a HEALTHCHECK on /healthz.
Nothing at run time writes to the filesystem; every setting comes from the environment
(see Configuration).
Every release from 2.0.0 on publishes this image as code.foss.global/stack.gallery/registry:<version>,
one tag for both architectures. Run exactly one container (see Known limitations).
serve.zone
The deployment is declared in .smartconfig.json under @git.zone/tsdeploy.deployment: the service
serves exactly registry.stack.gallery on container port 3000 (route proxied: false, readiness
/healthz answering 200), mounts no volumes, requires the database and objectstorage
capabilities, lists its complete environment surface, and has its release evidence produced by
tsdeploy's built-in generator (signing namespace registry.stack.gallery-release, hashing
pnpm-lock.yaml and Dockerfile_##version##, written to the git-ignored
.nogit/release-evidence.json).
tsdeploy deploy runs from a clean checkout of the release tag v<version>: the generator refuses a
revision that is not exactly the tagged release, or a worktree with any change. The first deploy is a
greenfield one; every later release uses --mode existing-service:
tsdeploy link --serviceId '<granted-service-id>'
TSDEPLOY_RELEASE_AUTHORIZED=true tsdeploy deploy --mode greenfield \
--organization-id 'Organization:<org>' \
--registry-username <deployment-machine-user>
Pinning the service to one node, the Corestore bindings, secrets, OIDC registration and the route
are described in the serve.zone guide Integrate a new app (developers/app-integration.md of
serve.zone/docs).
Run exactly one replica (see Known limitations).
Where each value comes from on serve.zone:
| Source | Variables |
|---|---|
Platform, database binding |
MONGODB_URI, MONGODB_URL, MONGO_URL, MONGODB_HOST, MONGODB_PORT, MONGODB_DATABASE, MONGO_DBNAME, MONGODB_USERNAME, MONGO_DBUSER, MONGODB_PASSWORD, MONGO_DBPASS (the registry reads MONGODB_URL and MONGODB_DATABASE) |
Platform, objectstorage binding |
S3_ENDPOINT, AWS_ENDPOINT_URL, S3_ENDPOINT_HOST, S3_PORT, S3_USE_SSL, S3_REGION, AWS_REGION, S3_BUCKET, AWS_S3_FORCE_PATH_STYLE, and the credential S3_ACCESS_KEY, S3_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID, S3_SECRET_KEY, S3_SECRET_ACCESS_KEY, AWS_SECRET_ACCESS_KEY (the registry reads the S3_* names of the Configuration table) |
| Service secrets the operator creates | PUBLIC_URL, JWT_SECRET, AUTH_ENCRYPTION_KEY, ADMIN_EMAIL + ADMIN_PASSWORD (first deploy), IDP_ISSUER + IDP_CLIENT_ID + IDP_CLIENT_SECRET (when idp.global sign-in is wanted) |
| Optional, normally unset | TRUSTED_PROXIES, SERVEZONE_PORT, PORT, HOST |
Never create a service secret under a platform-injected name; a service whose own environment holds
an object-storage binding name is refused (objectstorage-environment-conflict).
PUBLIC_URL:https://registry.stack.gallery.JWT_SECRETandAUTH_ENCRYPTION_KEY: let the platform generate them, 32 bytes each inbase64url(the dashboard's generated value, or{ mode: 'generated', encoding: 'base64url', bytes: 32 }), so the value never exists outside the platform. Use separate values, never shared with another service. Off the platform,openssl rand -base64 32produces a valid value for either.ADMIN_EMAILandADMIN_PASSWORD(at least 12 characters): the first platform admin. Startup needs them only while no platform admin exists; once the admin has signed in, both can be retired.IDP_ISSUER,IDP_CLIENT_ID,IDP_CLIENT_SECRET: register a confidential idp.global client with the exact redirect URIhttps://registry.stack.gallery/auth/callbackand store the returned client id and secret sealed.TRUSTED_PROXIEStakes exact addresses (no ranges). While it is unset, the client address of every request is the direct peer, which on serve.zone is the node's ingress, so all clients share the per-address sign-in rate limits and the audit log records the ingress address.- Leave
SERVEZONE_PORT,PORTandHOSTunset: the declaration and the route target port 3000 on all interfaces.
Single executable (not yet available)
There are no single executables and no working install.sh yet: @push.rocks/smartrequest 5, which
three dependencies still bring into the bundle, imports its Node.js core by a computed path that a
single executable cannot resolve. Until that is fixed upstream, run the container.
From source
pnpm install
pnpm build
pnpm start # node cli.js server
stack-gallery-registry help lists the environment the command reads.
⚙️ Configuration
Configuration is read from the environment only. A missing or malformed required value stops startup: there are no default credentials, endpoints, secrets or database names. Database and object storage use the names the serve.zone platform injects.
| Variable | Required | Description |
|---|---|---|
MONGODB_URL |
yes | MongoDB-protocol connection URL |
MONGODB_DATABASE |
yes | Database name |
S3_ENDPOINT_HOST |
yes | Object storage host, without scheme or port |
S3_PORT |
yes | Object storage port |
S3_USE_SSL |
yes | true or false |
S3_REGION |
yes | Object storage region |
S3_BUCKET |
yes | Bucket for the artifacts; it must exist |
S3_ACCESS_KEY, S3_SECRET_KEY |
yes | Object storage credentials |
PUBLIC_URL |
yes | The one public origin of the registry, https://… (http only on a loopback host). The OCI token realm, npm tarball URLs and the idp.global redirect URI derive from it |
JWT_SECRET |
yes | At least 32 characters; signs session and OCI bearer tokens |
AUTH_ENCRYPTION_KEY |
yes | Base64 of 32 random bytes; seals idp.global sign-in sessions |
ADMIN_EMAIL, ADMIN_PASSWORD |
while no platform admin exists | The first platform admin (password at least 12 characters). Startup creates it only while no platform admin exists and stops without it; an existing account is never promoted |
IDP_ISSUER, IDP_CLIENT_ID, IDP_CLIENT_SECRET |
no, all or none | idp.global sign-in. Register <PUBLIC_URL>/auth/callback as the exact redirect URI |
SERVEZONE_PORT, PORT |
no | Listener port, in this order; default 3000 |
HOST |
no | Listener address; default all interfaces |
TRUSTED_PROXIES |
no | Comma-separated proxy addresses whose X-Forwarded-For names the client for rate limits and audit |
🏢 Organizations, namespaces and roles
- npm: only scoped packages are served. Creating an organization claims the npm scope of its name
(
@<organization>/…); a platform admin can claim further scopes for an organization. A scope is owned by exactly one organization, and a claim stays reserved after its organization is deleted. - OCI: a repository is
<organization>/<repository>; the first path segment names the organization. - Repositories carry visibility, grants and token scopes. An npm repository is named by the full
package name,
@<scope>/<name>in a scope the organization claims, so the same package name in two scopes of one organization is two repositories; an OCI repository by its name below the organization. An organization has at most one repository of a protocol and name. - Roles: platform admins may do everything. Organization owners and admins may publish, delete
and manage the organization; only owners grant or take the owner role. Members read the
organization's artifacts and write where a team or repository grant allows it (repository roles
reader,developer,maintainer,admin). Anonymous callers read public repositories only. - Deleting a package or a version in the web UI deletes it through
@push.rocks/smartregistry, which removes the stored artifacts and asks the same access rule as the protocols: platform admins, organization owners and admins, and themaintainerandadminrepository roles may delete. An unpublished npm version can never be published again. - API tokens carry the owner's role and narrow it: to protocols (
npm,oci), to scopes of protocol, organization, repository and actions (read,write,delete). A token created for an organization works only inside it. Tokens are credentials for the protocols only; the management API does not accept them.
🔌 Using the registry
npm
# .npmrc
@myorg:registry=https://registry.example.com/-/npm/
//registry.example.com/-/npm/:_authToken=srg_…
npm publish
npm install @myorg/my-package
npm unpublish @myorg/my-package@1.0.0
The npm login and token endpoints mint nothing: create tokens in the web UI.
OCI / Docker
docker login registry.example.com -u token -p srg_… # or your email and password
docker push registry.example.com/myorg/myimage:1.0.0
docker pull registry.example.com/myorg/myimage:1.0.0
The challenge on /v2/ names <PUBLIC_URL>/v2/token. The token endpoint verifies the credential,
decides every requested repository:<name>:<actions> scope and answers a bearer token that is valid
for five minutes and grants exactly the decided access.
Storage that no repository holds any more (after a manifest or repository is deleted) is reclaimed by smartregistry's garbage collection, which the registry runs ten minutes after it starts and then once a day. It never touches anything younger than seven days.
🔐 Sign-in
- Accounts are created by platform admins; there is no self-registration. Passwords are hashed with scrypt; hashes of the 1.x format are verified and replaced at the next sign-in.
- Rate limits and lockout: failed credential checks are limited per client address and per
account. Five failed password checks lock the account for 15 minutes; failures while it is locked
are not counted, the first failure after the lock ran out starts a new count, and a successful
sign-in or a platform admin's unlock (
unlockUser) clears count and lock. - Sessions: a 15-minute access token and a 7-day refresh token name a database session; every check re-reads the session and the user, so a sign-out or a suspension takes effect at once. A revocation, sign-out, suspension or password reset that lands while a request is in flight is never written over: that token use, refresh or sign-in is refused.
- idp.global (optional) runs the authorization code flow with S256 PKCE, a single-use state bound
to the browser, a nonce and a validated ID token through
@idp.global/sdk/relyingparty. The ID token's issuer, audience, expiry and nonce are checked; its signature is not checked against the JWKS, because it arrives in the direct TLS answer of the token endpoint to a confidential client, which OpenID Connect Core 3.1.3.7 accepts. That is a deliberate decision for 2.0.0: signature checks belong in@idp.global/sdk(enableNonRepudiationChecks), not in the registry. No token travels in a URL: the page the sign-in returns to trades the HttpOnly cookie session for a registry session in a same-origin POST. An idp.global account signs in only when it is linked to an active local account, by issuer and subject; a signed-in user links it under Settings. It is never matched by email and never creates an account. - Password sign-in can be switched off for everyone but platform admins (Admin settings).
📡 HTTP surface
| Path | Purpose |
|---|---|
GET /healthz |
200 while the database and the object storage answer, otherwise 503 |
/auth/* |
idp.global sign-in (login, callback, finalize, session, logout), POST /auth/link, POST /auth/exchange |
POST /typedrequest |
the TypedRequest API of the web UI (ts_interfaces/), JSON bodies up to 1 MiB |
GET /v2/token |
OCI token endpoint |
/-/npm/* |
npm protocol; a publish body may be up to 64 MiB |
/v2/* |
OCI distribution protocol; manifests up to 4 MiB, blobs up to 2 GiB, at most 8 open uploads per user |
| everything else | the web UI |
Every response carries X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer,
X-Frame-Options: DENY and frame-ancestors 'none', and Strict-Transport-Security behind an https
PUBLIC_URL. Every npm and OCI operation, reads included, is authorized before anything is read
or stored; a protocol request body is read only after that and never beyond its limit.
⚠️ Known limitations
- Run exactly one instance (one replica) per database and bucket: credential rate limits and OCI upload sessions live in process memory, and smartregistry serialises garbage collection against pushes, and updates of shared documents such as packuments and tags, only within one process. A second replica splits the rate limits, loses uploads that land on the other one, and could lose updates or collect storage a push on the other one still needs.
- The organization storage quota (5 GiB by default) is approximate, not a hard limit: every stored object is checked against the bytes booked so far, and bytes are booked when a publish commits, so uploads in flight do not count against it. The protocol limits above bound how far one upload, and the uploads one user holds open, can go past it. A write that names no existing organization is refused.
🛠️ Development
pnpm install
pnpm build # UI bundle, Node bundle, dist_ts_interfaces, dist_ts
pnpm test # every test/test.*.node.ts
pnpm exec tsrepo test test/test.access.node.ts --verbose
pnpm exec tsrepo check 'test/**/*'
pnpm run build:docker # the container image, both architectures (tsdocker, local only)
pnpm run test:docker # builds the image and runs test/test_latest.sh inside it
pnpm run build:binaries # single executables (tsrepo sea); they do not start yet
Each test file starts its own in-memory NoSQLDB and a temporary ObjectStorage engine on 127.0.0.1
with OS-chosen ports; the end-to-end tests drive the npm command line and the OCI distribution API
against a registry on port 0. See AGENTS.md for the repository's conventions and pitfalls.
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.md 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.