RFC 034: incan.pub — The Incan Package Registry¶
- Status: Draft
- Created: 2026-03-06
- Author(s): Danny Meijer (@dannymeijer)
- Related: RFC 031 (library system phase 1), RFC 027 (incan-vocab)
- Issue: #168
- RFC PR: —
- Written against: v0.2
- Shipped in: —
Summary¶
Define the incan.pub package registry: the protocols, guarantees, and CLI commands that allow Incan library authors to publish packages and consumers to resolve them. The registry must be EU-hosted, integrity-verified, signature-aware, and operationally cheap enough to run with predictable capped spend. Exact vendor choice and launch-era cost numbers are implementation details, not the core contract.
Constraints¶
Two non-negotiable requirements drive every decision in this RFC:
-
EU infrastructure. The Incan project is partially funded by an EU grant. All data storage and compute must be hosted by EU-based providers. This rules out US-headquartered cloud providers (AWS, GCP, Azure, Cloudflare) as primary infrastructure. EU-based CDN is acceptable for edge caching.
-
Cost ceiling. The registry must not produce surprise bills. At every scale tier the monthly cost must be predictable and capped. The project cannot absorb a €1,000+/month bill from sudden popularity — cost must grow slowly and linearly, with hard limits enforced via provider spending caps and architectural choices (CDN offloading, bandwidth caps, immutable caching).
Motivation¶
RFC 031 introduces Incan libraries with local path dependencies. That's enough for monorepos and co-located projects, but the ecosystem needs a way to publish and discover shared packages. Without a registry:
- Library authors have no distribution channel beyond "clone my repo."
- Consumers cannot express versioned dependencies (
mylib = "0.1.0"). - There is no central discovery point for the Incan ecosystem.
- Supply chain security has no foundation (no checksums, no signatures, no audit trail).
The incan.pub registry closes this gap. It is the canonical source for published Incan packages, parallel to crates.io for Rust and PyPI for Python.
Guide-level explanation¶
Publishing a package¶
# One-time setup: create an account and save credentials
$ incan login
Opening https://incan.pub/tokens in your browser...
Paste your API token: ****
Saved to ~/.incan/credentials
# Publish from a library project
$ incan publish
Building library...
Packaging mylib 0.1.0...
Signing with Sigstore (GitHub: @dannymeijer)...
Uploading to incan.pub...
Published mylib 0.1.0
Consuming a published package¶
# my-app/incan.toml
[project]
name = "my-app"
version = "0.1.0"
[dependencies]
mylib = "0.1.0"
# src/main.incn
from pub::mylib import Widget
def main():
w = Widget(title="hello")
print(w)
$ incan build
Resolving dependencies...
mylib 0.1.0 (incan.pub)
Downloading mylib 0.1.0...
Verifying checksum... ok
Verifying signature... ok (signed by @dannymeijer via Sigstore)
Compiling my-app...
Done.
Yanking a version¶
$ incan yank mylib 0.1.0
Yanked mylib 0.1.0 — existing lockfiles still resolve, new resolves skip it.
What the user sees on incan.pub¶
incan.pub serves a static web page (like lib.rs or pypi.org) showing:
- Package name, description, version history
- Author, license, repository link
- Signature status and signer identity
- Download counts
- Dependency tree
This is a static site generated from the index — no dynamic server needed for the web UI.
Reference-level explanation¶
Architecture overview¶
┌─────────────────────────────┐
│ incan.pub │
│ (DNS) │
└────────────┬────────────────┘
│
GET /crates/*, GET /index/*
│
▼
┌─────────────────────────────┐
│ EU CDN / cache │
│ immutable package + index │
│ distribution │
└────────────┬────────────────┘
│ cache miss / API
▼
┌─────────────────────────────┐
│ Registry service │
│ publish, yank, auth, index │
│ update, verification │
└────────────┬────────────────┘
│
▼
┌─────────────────────────────┐
│ EU object storage + DB │
│ packages, index, signatures │
│ auth/ownership │
└─────────────────────────────┘
The package format¶
A .crate file is a gzipped tarball containing the Rust crate output from incan build --lib plus the .incnlib type manifest:
mylib-0.1.0.crate (tar.gz):
└── mylib-0.1.0/
├── Cargo.toml # Generated Rust crate metadata
├── src/
│ ├── lib.rs # Generated Rust source
│ └── widgets.rs
└── .incnlib # Type manifest (JSON, from RFC 031)
The .incnlib file is invisible to Cargo (which ignores unknown files in the tarball). The incan CLI extracts it for typechecking; cargo build only sees the Rust source.
This is a single artifact — the type manifest and compiled Rust source are never stored or transferred separately. This simplifies every part of the pipeline: publish uploads one file, download retrieves one file, cache stores one file.
Index format¶
The registry uses a sparse index format inspired by Cargo's sparse registry protocol. Each package has one file in the index, containing one JSON line per published version:
index/my/li/mylib
{"name":"mylib","vers":"0.1.0","cksum":"sha256:e3b0c442...","deps":[],"incan_version":">=0.2.0","yanked":false,"publisher":"dannymeijer","signatures":{"keyid":"sigstore-oidc","sig":"MEUC...","cert":"MIIB..."}}
{"name":"mylib","vers":"0.2.0","cksum":"sha256:a1b2c3d4...","deps":[{"name":"widgets","req":"^0.1"}],"incan_version":">=0.2.0","yanked":false,"publisher":"dannymeijer","signatures":{"keyid":"sigstore-oidc","sig":"MEYCIQDx...","cert":"MIIB..."}}
Index entry fields:
| Field | Type | Description |
|---|---|---|
name |
string | Package name |
vers |
string | SemVer version |
cksum |
string | SHA256 of the .crate tarball (prefixed with sha256:) |
deps |
array | Incan library dependencies (name + req version range) |
rust_deps |
array | Rust crate dependencies (merged into consumer's Cargo.toml) |
incan_version |
string | Minimum compiler version required |
yanked |
bool | If true, existing lockfiles still resolve but new resolves skip |
publisher |
string | Publisher identity (username) |
signatures |
object | null | Sigstore signature + certificate, or null if unsigned |
Index path convention (matches crates.io):
| Package name length | Path |
|---|---|
| 1 character | index/1/<name> |
| 2 characters | index/2/<name> |
| 3 characters | index/3/<first char>/<name> |
| 4+ characters | index/<first two>/<next two>/<name> |
Registry API¶
The registry service exposes a small HTTP API. All mutating endpoints require authentication.
POST /api/v1/publish¶
Headers:
Authorization: Bearer <token>
Content-Type: application/octet-stream
X-Package-Name: mylib
X-Package-Version: 0.1.0
X-Checksum: sha256:e3b0c442...
X-Signature: MEUC... (base64, optional in Phase 1)
X-Certificate: MIIB... (base64, optional in Phase 1)
Body: .crate tarball (binary)
Server-side validation:
- Verify token → resolve publisher identity
- Verify publisher owns this package name, or name is unclaimed
- Verify
(name, version)does not already exist → 409 Conflict - Verify
X-Checksummatches SHA256 of request body - If signature provided: verify Sigstore signature is valid, signer matches publisher
- Extract
.incnlibfrom tarball → verify it parses (basic structural validation) - Store
.cratein object storage:crates/<name>/<version>.crate - Store signature artifacts:
crates/<name>/<version>.crate.sig,.cert - Update index: append version line to
index/<prefix>/<name> - Invalidate CDN cache for the index entry 11. Return 200
Response: { "published": "mylib", "version": "0.1.0" }
POST /api/v1/yank¶
Headers:
Authorization: Bearer <token>
Body: { "name": "mylib", "version": "0.1.0" }
Sets yanked: true in the index entry. Does not delete the .crate file (existing lockfiles and builds that reference this exact version still work).
GET /index/<prefix>/<name>¶
Returns the JSON-lines index file for the named package. Served from object storage, cached at CDN edge.
GET /crates/<name>/<version>.crate¶
Returns the .crate tarball. Served from object storage, cached at CDN edge. Immutable forever — cache headers set to maximum TTL.
Authentication¶
Token-based auth¶
Publishers authenticate with API tokens. Tokens are generated via the incan.pub web UI (or a future incan token create CLI command).
Token storage:
- Server side: Scaleway Serverless Database (PostgreSQL, free tier: 1 GB) or a simple JSON file in object storage (sufficient for early scale). Maps token hash → publisher identity + package ownership list.
- Client side:
~/.incan/credentials(file permissions 0600). Format:[registry]\ntoken = "incan_tok_...".
$ incan login
Opening https://incan.pub/tokens in your browser...
Paste your API token: ****
Saved to ~/.incan/credentials
Package ownership¶
- First publish of a name → publisher becomes owner
- Owners can add co-owners via
incan owner add <username> <package> - Only owners can publish new versions or yank existing ones
- Reserved names:
std,incan,core,test— cannot be claimed by users
Package signing with Sigstore¶
Every incan publish signs the .crate tarball using Sigstore keyless signing:
Publish side:
incan publishinitiates an OIDC flow (opens browser → GitHub/GitLab/Google login)- Sigstore's Fulcio CA issues a short-lived signing certificate tied to the OIDC identity
- The
.cratefile's SHA256 digest is signed with the ephemeral private key - The signature + certificate + checksum are recorded in Sigstore's Rekor transparency log
- The signature and certificate are sent to the registry alongside the
.crate
Verification side (incan build):
- Download
.crate+.sig+.certfrom registry - Verify SHA256 of
.cratematches the index checksum - Verify the certificate was issued by Sigstore Fulcio CA
- Verify the signature matches the
.cratedigest - Verify the signer identity in the certificate matches the
publisherfield in the index - Verify the signature is recorded in Sigstore Rekor (transparency log lookup)
If verification fails, incan build refuses to use the package and emits a clear diagnostic.
Sigstore is optional in Phase 1 — the signatures field in the index is nullable. Unsigned packages are accepted but display a warning during incan build. The goal is to make signing the default from early on, then make it mandatory once the tooling is proven.
Implementation note: the service can rely on existing Sigstore client libraries rather than inventing its own signing stack. The registry still verifies signatures on publish so invalid artifacts are rejected early.
Security properties¶
| Threat | Mitigation |
|---|---|
| Unauthorized publish | API token required; validated server-side |
| Package tampering (in transit) | SHA256 checksum verified client-side after download |
| Package tampering (at rest / registry compromise) | Sigstore signature is independently verifiable; transparency log is external |
| Token theft | Sigstore OIDC ties signature to real identity, not just a token; stolen token can publish but signature won't match |
| Version overwrite | Server rejects duplicate (name, version) — immutable once published |
| Name squatting | Reserved names enforced; future: similarity checks |
| Dependency confusion | pub:: import prefix makes origin unambiguous (RFC 031) |
| Supply chain audit | Every publish recorded in Sigstore Rekor transparency log — public, append-only, external |
Consumer resolution flow¶
When incan build encounters a registry dependency:
[dependencies]
mylib = "0.1.0" # exact version
mylib = "^0.1" # SemVer-compatible range
mylib = { version = "0.1.0", registry = "incan.pub" } # explicit (default)
Resolution:
- Read
[dependencies]fromincan.toml - For each registry dep:
GET https://incan.pub/index/<prefix>/<name> - Parse JSON lines, filter by version requirement, select newest matching non-yanked version
- Check local cache
~/.incan/libs/<name>-<version>/— if cached and checksum matches, skip download GET https://incan.pub/crates/<name>/<version>.crate- Verify SHA256 checksum matches index entry
- Verify Sigstore signature (if present; warn if absent)
- Extract to
~/.incan/libs/<name>-<version>/ - Load
.incnlibinto typechecker symbol table - Wire Rust crate as path dependency in generated
Cargo.toml
Lockfile (incan.lock): on first resolution, write resolved versions + checksums to incan.lock. Subsequent builds use the lockfile for reproducibility. incan update re-resolves.
CLI commands¶
| Command | Description |
|---|---|
incan add <pkg> |
Add a dependency to incan.toml (fetch latest version from registry) |
incan remove <pkg> |
Remove a dependency from incan.toml |
incan update |
Re-resolve all dependencies and update incan.lock |
incan login |
Authenticate with incan.pub, save token to ~/.incan/credentials |
incan publish |
Build library, package .crate, sign, upload to registry |
incan yank <pkg> <ver> |
Mark a version as yanked (still downloadable but skipped in new resolves) |
incan search <query> |
Search the registry index (client-side text search over cached index) |
incan owner add <user> <pkg> |
Add a co-owner for a package |
incan owner list <pkg> |
List owners of a package |
incan add in detail¶
Like cargo add, this is the primary way users add dependencies. It edits incan.toml for you:
# Add latest version from incan.pub
$ incan add widgets
Added widgets = "^0.2.1" to [dependencies]
# Add a specific version
$ incan add widgets@0.1.0
Added widgets = "0.1.0" to [dependencies]
# Add a Rust crate (to [rust-dependencies])
$ incan add --rust serde
Added serde = "1.0" to [rust-dependencies]
# Add a path dependency (local library)
$ incan add widgets --path ../widgets
Added widgets = { path = "../widgets" } to [dependencies]
# Add a git dependency (Phase 2)
$ incan add widgets --git https://github.com/example/widgets --tag v0.2.0
Added widgets = { git = "https://...", tag = "v0.2.0" } to [dependencies]
Behavior:
- If no
incan.tomlexists, error with "runincan initfirst" - Query the registry index for the latest non-yanked version (unless
@versionor--path/--gitspecified) - Default to
^major.minor.patchrange (SemVer-compatible, like Cargo) - Write the entry to
[dependencies](or[rust-dependencies]with--rust) - If the package is already in
incan.toml, update the version (with a confirmation prompt unless--force) - Run
incan lockto updateincan.lockwith the resolved version
incan remove does the inverse: removes the entry from incan.toml and re-locks.
Infrastructure: provider selection¶
Requirements¶
| Requirement | Rationale |
|---|---|
| EU-headquartered provider | EU grant compliance; GDPR-native |
| Predictable cost with hard caps | Project cannot absorb surprise bills from scaling |
| S3-compatible object storage | Standard tooling; provider-portable |
| Scale-to-zero compute | No cost when idle; only pay for publishes |
| CDN with configurable bandwidth cap | Read traffic offloaded to edge; cap prevents bill shock |
Reference deployment candidates (informative)¶
| Component | Provider | Country | Why |
|---|---|---|---|
| Object storage | Scaleway Object Storage | France | S3-compatible, 75 GB free, €0.01/GB beyond, EU data residency |
| Compute | Scaleway Serverless Containers | France | Scales to zero, 400K vCPU-s/month free, deploy Incan binary as container |
| CDN | Bunny.net | Slovenia | EU-based, €0.005-0.01/GB, configurable monthly bandwidth cap, EU-only PoPs option |
| DNS | Scaleway or registrar | EU | Point incan.pub at CDN |
Illustrative cost envelope (informative)¶
| Stage | Packages | Downloads/month | Storage | CDN bandwidth | Compute | Total | Cap |
|---|---|---|---|---|---|---|---|
| Launch | <100 | <1K | €0 (free tier) | €0 (<1 GB) | €0 (free tier) | €0 | €5 cap on Scaleway |
| Growing | ~1K | ~50K | ~€1 | ~€5 | €0 | ~€6 | €20 cap |
| Traction | ~5K | ~500K | ~€5 | ~€25 | ~€5 | ~€35 | €75 cap |
| Large | ~50K | ~5M | ~€50 | ~€250 | ~€15 | ~€315 | €500 cap |
These figures are illustrative deployment planning guidance rather than normative RFC commitments.
How caps work:
- Scaleway: billing alerts + hard spending limits per project. Set a monthly budget; services are suspended (not billed) when exceeded.
- Bunny.net: configurable monthly bandwidth limit per pull zone. When reached, traffic returns 503 (or falls through to origin with a smaller rate limit). Set to the cap column value; increase manually as growth justifies it.
The worst case of "sudden PyPI-scale fame" is: CDN hits its bandwidth cap, new downloads get 503, existing cached packages keep working. You notice, evaluate whether to raise the cap, and decide. You never wake up to a €10K bill.
Provider portability¶
The registry service should talk to object storage via an S3-compatible API or equivalent portable abstraction, configured through ordinary deployment variables or service configuration. Switching providers should mean changing configuration and migrating stored objects, not rewriting registry logic.
Why not self-hosted Kellnr?¶
Kellnr is a self-hosted Rust crate registry that implements the Cargo registry protocol. It was considered and rejected because:
- It only speaks the Cargo registry protocol — no awareness of
.incnlibmanifests - Requires a persistent server (no scale-to-zero)
- Written in Rust, not Incan (misses the dogfooding opportunity)
- The
.incnlib-in-.cratetrick makes Cargo protocol compatibility free anyway — any tool that can download a.crategets both the Rust source and the type manifest
Reference service implementation (informative)¶
The preferred long-term direction is for the incan.pub registry API to be an Incan service. That gives the project a strong dogfooding story and validates that Incan can support its own ecosystem infrastructure. However, the language used for the first service implementation is not the public contract of this RFC. The contract is the registry protocol, package format, integrity model, and CLI behavior.
The important design constraint is portability:
- the service must be able to run on EU-hosted infrastructure with hard cost caps;
- object storage and CDN choices should remain replaceable;
- the registry protocol must not depend on whether the service is implemented in Incan or in a temporary bootstrap implementation.
Interaction with existing features¶
- RFC 031 (library system): This RFC builds directly on RFC 031. The
.incnlibmanifest format,pub::import syntax, andincan build --libcommand are defined there. This RFC adds the distribution layer on top. - RFC 027 (incan-vocab): Library soft keyword declarations are serialized into the
.incnlibmanifest duringincan build --liband included in the.cratetarball. The registry is unaware of soft keywords — it just stores and serves packages. rust::imports (RFC 005):pub::registry imports andrust::Rust crate imports coexist. A package's Rust dependencies (from its generatedCargo.toml) are listed in the index entry'srust_depsfield.
Alternatives considered¶
Cloudflare Workers + R2¶
US-headquartered, disqualified by the EU infrastructure requirement. Cloudflare's EU-only deployment options exist but the company remains US-based.
GitHub Releases as package storage¶
Free but ties the ecosystem to a US platform. Also lacks integrity guarantees — release assets can be silently re-uploaded.
Self-hosted Kellnr¶
See "Why not self-hosted Kellnr?" above.
Static git repository (no compute)¶
A git repo deployed as a static site (like incan.io). Works for reads but cannot validate publishes server-side — anyone with push access can publish anything. No auth, no ownership, no validation. Acceptable for first-party-only use but not for a community registry.
Hetzner VPS + Object Storage¶
German provider, good pricing. However: no scale-to-zero compute (minimum €4.51/month even when idle), no serverless containers. Viable as a fallback but Scaleway's free tier and serverless model are a better fit for the early stage.
Drawbacks¶
- Complexity. A package registry is a significant piece of infrastructure to build and maintain, even a simple one.
- Dependency on Scaleway/Bunny.net. The architecture is provider-portable (S3 API + HTTP CDN), but the initial deployment is tied to these specific providers.
- Sigstore learning curve. Keyless signing via OIDC is unfamiliar to many developers. Clear documentation and good CLI UX can mitigate this.
- Bootstrap dependency. A first-class Incan implementation depends on the relevant web/runtime capabilities shipping in time. A temporary bootstrap implementation may be needed if ecosystem timing forces the registry to arrive earlier.
Implementation architecture¶
Phase 1: MVP registry (alongside RFC 031 implementation)¶
- Registry service for publish, yank, index reads, and package downloads
- Object storage integration for package, index, and signature artifacts
incan logincredential managementincan publishpackaging and upload flow- Index reader and package cache integration on the consumer side
- Lockfile integration consistent with the broader library-resolution story
Phase 2: Sigstore signing¶
incan publishintegration for keyless signing- Registry-side signature verification on publish
- Consumer-side verification during download/build
- Web and CLI surfacing of signer identity and verification status
Phase 3: Web UI + search¶
- Static site generation for package pages from index data
incan searchover registry metadata- Download counters or equivalent package-usage reporting
Layers affected¶
- Registry service: must implement package publication, index updates, integrity verification, and storage semantics consistent with the RFC.
- CLI / client: must support publish, resolution, download, verification, and cache-management behavior against the registry protocol.
- Dependency resolution / lockfile tooling: must consume registry metadata in a way that composes with the broader library and lockfile story.
- Infra / operations: must preserve the EU-hosting and hard-cost-cap constraints that are core to this RFC's design.
- Docs / ecosystem guidance: must explain publish, signing, verification, and failure-mode behavior clearly for package authors and consumers.
Unresolved questions¶
-
Token management UX. Should tokens be scoped (per-package, read-only, full-access)? Scoped tokens reduce blast radius of token theft but add complexity. Start with full-access tokens and add scoping later?
-
Version resolution strategy. Cargo uses SemVer with maximal version resolution. Should Incan do the same, or default to minimal version resolution (Cargo's
-Z minimal-versions) for reproducibility? Maximal is more familiar; minimal is safer. -
Namespace / scope model. Should packages be flat (
mylib) or scoped (@danny/mylib)? Flat is simpler; scoped prevents name conflicts as the ecosystem grows. PyPI is flat; npm is scoped. Cargo is flat with afoo/foo-barconvention. -
CDN cache invalidation on publish. When a new version is published, the index entry must be updated at the CDN edge. Bunny.net supports API-based purge — but should the registry service call it synchronously (slower publish, instant availability) or asynchronously (fast publish, ~60s propagation delay)?
-
Fallback when CDN cap is reached. When Bunny.net hits its bandwidth cap, should the client fall back to direct origin requests (slower but functional) or fail with a clear "registry bandwidth limit reached" error? Fallback is better UX but could overload the origin.
Future extensions (out of scope)¶
- Private registries. Organizations running their own
incan.pub-compatible registry for internal packages. Uses the same protocol and CLI commands with a different registry URL inincan.toml. - Trusted Publishers (GitHub Actions OIDC). Like PyPI's Trusted Publishers — CI/CD can publish without long-lived tokens by using GitHub Actions' OIDC identity directly with Sigstore.
- Package auditing tools.
incan auditto check dependencies against a vulnerability database. - Mirror support. Read-only mirrors of
incan.pubfor network-restricted environments or regional performance.