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)
- Target version: TBD
Summary¶
Define the incan.pub package registry: the infrastructure, protocols, and CLI commands that allow Incan library authors to publish packages and consumers to resolve them. The registry is an Incan web service backed by S3-compatible European object storage, with SHA256 integrity verification and Sigstore-based package signing. It costs €0 at launch and scales predictably with hard spending caps.
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/* │ POST /api/v1/*
GET /index/* │ (publish, yank, login)
│ │
▼ ▼
┌────────────────────────────────────┐
│ CDN Layer (Bunny.net — Slovenia) │
│ Caches all GET requests at edge │
│ Hard bandwidth cap configurable │
│ EU PoPs only (or worldwide later) │
└────────┬───────────────────────────┘
│ cache miss
▼
┌────────────────────────────────────┐
│ Registry Service │
│ (Incan binary on Scaleway │
│ Serverless Containers — Paris) │
│ │
│ Handles: │
│ - POST /api/v1/publish │
│ - POST /api/v1/yank │
│ - POST /api/v1/login │
│ - GET fallthrough (cache miss) │
│ Scales to zero when idle │
└────────┬───────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Scaleway Object Storage (Paris) │
│ S3-compatible │
│ 75 GB free tier │
│ │
│ Bucket layout: │
│ crates/<name>/<ver>.crate │
│ crates/<name>/<ver>.crate.sig │
│ crates/<name>/<ver>.crate.cert │
│ index/<prefix>/<name> │
└────────────────────────────────────┘
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
- 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.
Rust integration: the sigstore-rs crate provides the Sigstore client libraries. The registry service itself verifies signatures on publish (to reject invalid signatures 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 |
Selected providers¶
| 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 |
Cost projections with hard caps¶
| 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 |
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 talks to object storage via the S3 API (rust::aws_sdk_s3). Configuration is environment variables:
S3_ENDPOINT=https://s3.fr-par.scw.cloud
S3_BUCKET=incan-pub
S3_REGION=fr-par
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
Switching providers means changing these variables and running an rclone sync to migrate existing objects. Zero code changes.
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
The registry service: written in Incan¶
The incan.pub registry API is itself an Incan project. This is deliberate:
- Dogfooding. The registry is the first production Incan web service. If
std.webcan't handle it, that's a bug we need to find. - Marketing. "Our package registry is written in Incan" demonstrates the language's production readiness.
- Simplicity. Incan compiles to a native Rust binary — no runtime dependencies, no container base image beyond
scratch.
# registry/src/main.incn
import std.web
from std.web import App, Request, Response, route, status
from handlers import publish, yank, get_index, download_crate
from middleware import require_auth, log_request
app = App()
app.use(log_request)
@route("POST", "/api/v1/publish")
@require_auth
async def handle_publish(req: Request) -> Response:
return await publish(req)
@route("POST", "/api/v1/yank")
@require_auth
async def handle_yank(req: Request) -> Response:
return await yank(req)
@route("GET", "/index/{prefix}/{name}")
async def handle_index(req: Request) -> Response:
return await get_index(req)
@route("GET", "/crates/{name}/{version}.crate")
async def handle_download(req: Request) -> Response:
return await download_crate(req)
app.run(host="0.0.0.0", port=8080)
Deployed as a Docker container on Scaleway Serverless Containers. The container image is minimal (~10 MB): the compiled binary plus TLS root certificates.
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.
std.webdependency. Writing the registry in Incan meansstd.web(RFC 023) must ship first. Ifstd.webis delayed, the registry could be written in Rust as a temporary measure and rewritten in Incan later.
Implementation plan¶
Phase 1: MVP registry (alongside RFC 031 implementation)¶
| Component | What | Estimated size |
|---|---|---|
| Registry service (Incan) | HTTP API: publish, yank, index, download | ~300 lines |
| S3 storage layer | Object storage client for crate + index read/write | ~150 lines |
incan login CLI command |
Token prompt, save to ~/.incan/credentials |
~50 lines |
incan publish CLI command |
Build lib, package .crate, upload with checksum |
~150 lines |
| Index reader (in compiler) | Fetch + parse sparse index, resolve versions | ~200 lines |
| Cache manager | ~/.incan/libs/ cache with checksum verification |
~100 lines |
Lockfile (incan.lock) |
Write/read resolved dependency versions | ~150 lines |
| Scaleway + Bunny.net setup | Infra provisioning (one-time) | ~2 hours |
Phase 2: Sigstore signing¶
| Component | What | Estimated size |
|---|---|---|
Signing in incan publish |
OIDC flow + Sigstore signing via sigstore-rs |
~80 lines |
Verification in incan build |
Signature + certificate + Rekor verification | ~100 lines |
| Registry-side validation | Verify signature on publish before storing | ~50 lines |
Phase 3: Web UI + search¶
| Component | What |
|---|---|
| Static site generator | Generate HTML from index data (package pages, search) |
incan search CLI |
Client-side search over cached index |
| Download counters | Increment counter in object storage metadata on each download |
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.