jkunz 3a9ed20d2f fix(storage): refuse writes without a known organization and set explicit limits
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.
2026-10-21 12:07:32 +00:00
2026-10-21 10:23:41 +00:00
2026-10-21 10:23:41 +00:00

@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: /healthz answers 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_SECRET and AUTH_ENCRYPTION_KEY: let the platform generate them, 32 bytes each in base64url (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 32 produces a valid value for either.
  • ADMIN_EMAIL and ADMIN_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 URI https://registry.stack.gallery/auth/callback and store the returned client id and secret sealed.
  • TRUSTED_PROXIES takes 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, PORT and HOST unset: 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 the maintainer and admin repository 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.

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.

S
Description
the stack.gallery registry
Readme
2.1 MiB
2026-03-22 08:59:34 +00:00
Languages
TypeScript 97.9%
Shell 1.9%
HTML 0.2%