Tilo App Registry
The registry is Tilo’s semi-federated marketplace for .tapp
bundles. It advertises signed metadata so users can browse,
install, and update packages from the shell. Anyone can host
their own registry. The Tilo deployment is publicly reachable at apps.tilo.so
using the server under crates/packaging/registry-server.
Continuous app packaging currently publishes immutable tapps and collection bundles to Forgejo’s generic package registry. That is the cross-workflow artifact channel used by app, desktop and OS jobs; it is distinct from the signed catalogue and moderation service described here. Main-branch app publication now submits newly built tapps to that service; separate collector and networkless signer jobs independently verify accepted requests before advancing its signed snapshot. Production readiness still depends on the deployed owner/publisher trust, delegated-key rotation and backup path.
The signed client logic ships in the host app command and powers the graphical
Store’s repository view.
The registry architecture covers self-hosted bootstrap, publisher-allowlist workflow, TLS setup, backup/restore, and moderation hooks.
Architecture
There are three layers:
-
registry-types(crates/packaging/registry-types) defines the wire types —RegistryHandshake,RegistryEntry,EntryChangeRequest. Serde JSON on the wire, postcard for the canonical signing form. Verification helpers live here so both the server and any client (native, web, CLI) share the same code. -
registry-server(crates/packaging/registry-server) is a self-hostable HTTP binary that stores entries on disk and verifies every mutating request against an owner-controlled publisher allowlist, stages it for review, and exposes only approved entries to clients. -
The shared catalogue client queries the server, requires a preconfigured owner pin, verifies the complete snapshot, and drives the verified installer path. The Store and desktop search use this same client.
Trust chain
Registry owner offline key
| signs a short-lived, publisher-scoped channel authorization
v
Delegated channel key (networkless) <--- independently verifies staged tapp bytes
| signs exact policy + entries + expiry + moderation head
v
SignedRegistrySnapshot <--- publisher signs each RegistryEntry
|
| pinned owner verification + durable anti-rollback checkpoint
v
verified .tapp root ---> tilo_app_package::verify publisher identity again
The owner signs an index snapshot for federation
(INDEX_DOMAIN). Each publisher signs their own entry
(ENTRY_DOMAIN). The owner-controlled publishers.toml
allowlists which publisher pubkeys may submit changes and whether
each publisher may propose new entries or only changes to entries
they already own. Admission is deliberately not approval: every
accepted add, update, and delete remains private until an owner
reviews it.
Trust flow on the client:
- Client pins the owner pubkey (from
RegistryHandshake). - Client verifies each entry signature against the entry’s
declared
publisher_pubkey_hex, then checks that pubkey against the allowlist snapshot the owner signed. - Client downloads the
.tapp, verifies the BLAKE3 root hash matches thetapp_root_hash_hexin the entry, then runstilo_app_package::verify(which itself checks the publisher’s own signature over the manifest — a second, independent proof).
Canonical signing form
publisher entry sig = ed25519_sign(publisher_key,
blake3(b"tilo-registry-entry-v1\0"
|| postcard(entry_without_signature)))
publisher change sig = ed25519_sign(publisher_key,
blake3(b"tilo-registry-entry-v1\0"
|| postcard((action,
entry_without_signature))))
bot entry/change sig = the same, under b"tilo-registry-bot-entry-v1\0"
bots sig = ed25519_sign(owner_or_channel_key,
blake3(b"tilo-registry-bots-v1\0"
|| postcard((index digest, bots))))
owner index sig = ed25519_sign(owner_key,
blake3(b"tilo-registry-index-v1\0"
|| postcard(index_manifest)))
delegated index sig = ed25519_sign(channel_key,
blake3(b"tilo-registry-index-v3\0"
|| postcard(index_manifest
+ root authorization)))
Bot entries were added without changing what any released client reads.
An app entry encodes, signs and verifies byte for byte as before: its
kind is omitted and its versions keep their flat tapp_* fields. A bot
entry carries "kind": "bot", its versions carry bot instead of the
tapp_* fields, and its signature uses the bot domain, so neither kind can
pass for the other. Snapshots list bot entries under bots, beside
entries, with bots_signature_hex: the same key’s signature over the bot
entries and the digest the snapshot’s own signature covers (the compact
form uses tilo-registry-compact-bots-v1). The owner signature and the
snapshot hash cover entries only, so a client that predates bots ignores
both new fields and verifies the snapshot unchanged. See
architecture/bots.md §10 for bot entries.
Postcard was chosen over JSON because JSON’s key order and
whitespace are not deterministic across encoders — signatures
generated on one host would refuse to verify after a re-encode
on the other. Postcard fixes the byte order per struct field
and the length prefix on collections. See
crates/packaging/tilo-app-package::trust for the same pattern applied to
.tapp attestation signatures.
REST API
GET /registry → RegistryHandshake (server identity)
GET /health → { "ok": true }
GET /entries → SignedRegistrySnapshot
GET /entries?format=compact → CompactRegistrySnapshot (the full one until it exists)
GET /entries/<pkg-id> → SignedRegistrySnapshot (404 if not present)
GET /indexes/<blake3>.json → RegistryVersionIndex (named by a compact snapshot)
POST /entries → EntryChangeRequest body (202/404/409/403)
GET /thin/<root>.tapp → thin form of a reviewed package, by package root
GET /blobs/<blake3> → one payload blob of a reviewed package
GET /artifacts/<blake3>.tapp → a full .tapp stored with `import-artifact`
GET /blobs/<blake3>.zst, /artifacts/<blake3>.tapp.zst → their compressed twins
PUT /uploads/<blake3>.tapp → a publisher's package, private until review
PUT /uploads/<blake3>.program → a publisher's bot program, private until review
(201/400/401/403/413/422)
/entries carries Cache-Control: no-cache and ETag: "<snapshot hash>"
and answers a matching If-None-Match with 304, so a returning client
costs one small revalidation; the compact form has its own ETag. The
content-addressed routes are
immutable (Cache-Control: public, max-age=31536000, immutable); the server
re-checks every file against its name before serving it, and clients verify
again. A blob that is a WebAssembly binary is served as application/wasm
so browsers can compile it while it streams; everything else is
application/octet-stream, with X-Content-Type-Options: nosniff.
Approving a submission (review approve) stores the thin form and payload
blobs of every reviewed version, so a client that already has some blobs of
an earlier release downloads only the new ones.
Compressed twins
Packages and payload objects travel compressed. Beside each content-addressed
object the registry keeps a twin, the same bytes as one zstd frame at
level 19 (tilo_app_package::compressed): /blobs/<blake3>.zst for every
blob and chunk, /artifacts/<blake3>.tapp.zst for every imported package.
The release pipeline uploads <file>.tapp.zst beside every tapp it
publishes (tilo-app-package compress). Measured on real packages, twins are
34–42 % of an app tapp and 32 % of the runtime’s payload.
A twin is transport only. Signatures, tapp_blake3_hex, tapp_size_bytes,
blob names and chunk lists all describe the uncompressed bytes, and nothing
signs the twin. A client asks for <url>.zst first, decompresses it while it
streams with a hard bound — the signed tapp_size_bytes for a package, the
listed length for a chunk, the 64 MiB object ceiling for a whole blob — so a
small twin cannot expand into more than the object it stands for, and then
verifies the decompressed bytes exactly as it verifies a raw download. A host
without a twin answers 404 and the client fetches the raw object, so packages
published before twins and registries that keep none keep working; clients
that predate twins never ask for them and get raw bytes as before. A twin is
labelled application/zstd and never sent with Content-Encoding, so no
proxy or HTTP client decodes it on the way.
The registry writes a twin when it stores the object, from bytes it has already checked, and re-checks the twin against the object’s hash before serving it. Pruning removes a twin with its object. For objects stored before twins existed, run once after upgrading:
tilo-registry-server compress-payloads
It prints how many twins it wrote and their size, and is safe to rerun while
serve runs.
Browsers. serve --cors-origin <origin> (repeatable) lets pages on an
exact serialized origin such as https://tilo.so or http://localhost:4321
read GET responses: those responses carry Vary: Origin and, for a listed
Origin, Access-Control-Allow-Origin: <origin>. Nothing else is granted:
no credentials, no preflight answers, no cross-origin POST. Without the
flag no CORS headers are sent. Clients revalidate the snapshot with
fetch(url, {cache: 'no-cache'}), which lets the browser add
If-None-Match itself; setting the header by hand would need a preflight.
POST /entries accepts an EntryChangeRequest with an action
of add, update, or delete. All three must carry:
- a valid change-level publisher signature over the (action, entry-without-sig) preimage;
- a valid entry-level publisher signature over the entry-without-sig preimage;
- a
publisher_pubkey_hexmatching a row inpublishers.toml.
add and update also require the package_id to lie in one of that row’s
namespaces: com.acme.notes needs com.acme. Package ids are
lowercase reverse DNS with at least three labels
(registry_types::namespace). A row without namespaces (or with an empty
list) owns nothing: it can still delete its own entries but can add or update
none. so.tilo is granted only by naming it.
update and delete additionally require that the
publisher_pubkey_hex matches the stored entry’s — publishers
may only edit their own entries. update further requires
updated_at_unix > stored.updated_at_unix to reject stale
replays. A valid request returns 202 Accepted with
{"status":"pending_review","id":"<package-id>"}. GET requests
never expose pending payloads. They expose only the most recently published,
owner-signed snapshot: approval changes staging state, and the public catalogue
changes only after the offline signing step below.
Publisher applications
A key that is not allowlisted yet asks for namespaces with
POST /publishers/applications, sending a signed
registry_types::application::PublisherApplication: the key, up to eight
claims, a contact address for the owner and a note. Each claim is proven by
publishing the line tilo-publisher=<pubkey hex> where only the namespace’s
owner can write it:
| Claim | Namespace | Where the proof lives |
|---|---|---|
domain example.com |
com.example |
DNS TXT at _tilo-publisher.example.com, else https://example.com/.well-known/tilo-publisher.txt |
GitHub account ada |
io.github.ada |
tilo-publisher.txt in the public repository ada/tilo-publisher |
Codeberg account ada |
org.codeberg.ada |
the same file and repository on Codeberg |
Forgejo or Gitea at git.example.net, account ada |
net.example.git.ada |
the same file and repository on that host |
The registry verifies the signature, refuses applications dated more than a
day from its clock, reads every proof and answers 202 Accepted with the
pending status, or 422 naming the namespace, the expected line and every
place it looked. The application then waits for the owner;
GET /publishers/applications/<pubkey> reports its status and proven
namespaces but never the contact address. Nothing is approved over HTTP:
tilo-registry-server applications list
tilo-registry-server applications show <pubkey> --json
tilo-registry-server applications approve <pubkey> --reviewer <name>
tilo-registry-server applications reject <pubkey> --reviewer <name> --reason <text>
approve reads the proofs again, so a proof withdrawn while the application
waited fails the approval and changes nothing. Otherwise it allowlists the key
with --can-add-new for the proven namespaces, adding them to any it already
had.
For a UI or a script that decides what it previously displayed, take review_id
from applications show --json and pass it to either decision:
tilo-registry-server applications approve <pubkey> --reviewer <name> --review-id <id>
tilo-registry-server applications reject <pubkey> --reviewer <name> --reason <text> --review-id <id>
The id identifies the complete stored application, including its signed claims
and review state. If the publisher replaces it, refresh and review the new
request; an old decision changes neither the allowlist nor the replacement.
Approval also compares the reviewed snapshot under the transaction lock after
checking proofs, so a replacement arriving during a DNS/HTTPS check cannot be
overwritten. Rust callers use applications::approve_reviewed or
applications::reject_reviewed with the displayed StoredApplication.
The original CLI forms remain supported. These commands use existing local
operator access; they introduce no remotely callable approval endpoint.
Native operator review
On the registry host, the native operator tool uses the same local review
library as the CLI. Build it with cargo build --locked -p registry-review,
then open an explicitly private window:
target/debug/tilo-registry-review --private --registry-dir /path/to/registry
Choose Refresh applications, inspect the signed request, its exact namespaces, contact, note and proof locations, then name the reviewer. Approve namespaces checks ownership proofs again. Reject with feedback returns the written reason through the publisher’s existing status endpoint. Both decisions require confirmation and refuse a replacement application; refresh and inspect the new request before deciding again.
The pending queue is device-local view state. Only reviewer/feedback drafts use the SDK document and its undo/history on a disposable private profile; no registry queue or contact data is synced. Revoking owner access hides the review pane and refuses its actions. A shared launch reads no registry data. The tool needs the operator’s existing filesystem access to an initialized registry; it grants no access, opens no approval service and holds no registry signing key. Public publication still uses the registry’s existing signing workflow. The same window also reviews package submissions through the CLI’s shared signed-byte inspection and approval workflow.
The action surface is Refresh, Inspect {publisher}, followed by
Approve {publisher, review_id, reviewer} or
Reject {publisher, review_id, reviewer, reason}. Inspect returns the exact
snapshot and review id; automated operators use the same confirmation and
stale-review checks as the controls. Queue pages contain at most 20 rows.
Choose Refresh packages to list pending package summaries without downloading anything. Verify and inspect packages explicitly reads uploaded artifacts or fetches external versions with the CLI’s bounded, public-network-only policy. The detail view shows verified versions, byte counts and signed sandbox requests; review never launches a package. Verification errors remain visible and block approval while leaving rejection available. Name the reviewer and write a reason before either confirmed decision. Approval rechecks bytes, current authorization and the exact pending request before staging metadata and publishing reviewed uploads. Signed registry snapshot publication remains a separate operator step.
Package agents use PackageReview::RefreshPackages, InspectPackage {package}, then
ApprovePackage {package, review_id, reviewer, reason} or
RejectPackage {package, review_id, reviewer, reason}. Inspection returns the full
submission, its review id, and either verified_packages or inspection_error.
Refresh and PackagePage {page} return at most 20 addressable summaries; no package
bytes or full signatures are returned in discovery. Registration and package
operations share one busy state, the same private-window requirement and owner
permission checks. Switching inspected requests clears feedback for the previous
request; registry state stays outside draft history.
Bootstrapping your own instance
1. Generate an owner keypair
Use tilo-app-package-cli genkey (or any Ed25519 CLI). Keep the private key
in a hardware token if you can; publish only the pubkey.
2. Init the data dir
tilo-registry-server init \
--owner-pubkey <hex> \
--name "acme registry" \
--description "Apps for the acme fleet" \
--base-url https://apps.acme.example
This writes handshake.toml under $XDG_STATE_HOME/tilo-registry
(default: ~/.local/state/tilo-registry, or /var/lib/tilo-registry
under the systemd unit).
3. Allowlist your first publisher
tilo-registry-server add-publisher \
--pubkey <publisher-hex> \
--can-add-new \
--namespace com.acme \
--note "in-house team"
--can-add-new says this publisher may submit new entries. Without
it, they may only propose edits or deletion of their own rows —
useful for onboarding a publisher who’s inheriting an existing
entry. --namespace (repeatable) is the reverse-DNS prefix the publisher’s
package ids must start with; it is required and has no default. Naming Tilo’s
own namespace so.tilo (or one inside it) also requires --first-party, so a
first-party grant is always deliberate:
tilo-registry-server add-publisher \
--pubkey <publisher-hex> \
--can-add-new \
--namespace so.tilo \
--first-party
Upgrading an existing registry. Rows written before namespaces existed
were read as so.tilo; they now own nothing. serve and list-publishers
log a warning naming each such key, and list-publishers shows it with
namespaces = []. For every key that genuinely publishes Tilo’s own apps,
rerun add-publisher --pubkey <hex> [--can-add-new] --namespace so.tilo --first-party (it replaces the row); give any other key its own namespace.
Until then those keys can still delete their entries, and moderation keeps
working.
4. Serve
tilo-registry-server serve --port 8443 --bind 127.0.0.1
The production OCI image uses the same interface and contains no TLS termination or private signing material:
registry.tilo.so/tilo/tilo-registry-server@sha256:<digest>
/usr/local/bin/tilo-registry-server \
--data-dir /var/lib/tilo-registry serve --bind 0.0.0.0 --port 8443
It runs as UID/GID 65532 and supports a read-only root filesystem. Mount the
state directory read-write, mount /tmp as a small tmpfs, and probe
GET /health on port 8443. Main-branch CI publishes only an immutable
sha-<full-source-revision> tag and records the deployable digest in the
registry-server-image-receipt workflow artifact.
Behind nginx / Caddy — see TLS below.
5. Publisher workflow
Publishers build a RegistryEntry, sign it with sign_entry
(from registry-types), then POST an EntryChangeRequest to
/entries. Publisher tooling should derive the advertised root hash and
size from the final locally-built .tapp, never from an intermediate build.
CI can generate the complete request from those final bytes with the same
identity that signed the package:
tilo-app-package entry \
--identity /run/secrets/app-publisher.json \
--action update \
--previous-entry current-entry.json \
--metadata registry-metadata.json \
--url https://apps.tilo.so/artifacts/immutable/app.tapp \
app.tapp > request.json
curl --fail --header 'Content-Type: application/json' \
--data-binary @request.json https://apps.tilo.so/entries
Use --action add for the first version. The command refuses an identity that
does not match the publisher embedded in the tapp, so the registry submission
cannot relabel artifacts signed by another key. Updates require the current
signed entry and append to its immutable version history; re-running the same
artifact reproduces the existing entry, while reusing a version/target identity
for different bytes fails closed.
Artifact URLs must be
under https://apps.tilo.so/artifacts/; a registry whose base_url is a
loopback address (http://127.0.0.1:<port>, http://localhost:<port>,
http://[::1]:<port>) also accepts its own <base_url>/artifacts/, where
tilo-registry-server import-artifact <file.tapp> hosts a file and prints
its URL.
A publisher without its own artifact hosting uploads the file to the registry
instead: PUT /uploads/<blake3>.tapp with the exact bytes, its public key in
Tilo-Publisher and, in Tilo-Signature, its Ed25519 signature over
"tilo-registry-upload-v1\0" followed by the lowercase BLAKE3 hex
(registry_types::upload). The registry accepts only an allowlisted
publisher’s upload of a package that publisher signed, within the package’s
size limit, and answers 201 with {"blake3", "url"}. The URL is
https://apps.tilo.so/artifacts/<blake3>.tapp, or the loopback prefix on a
local registry. Uploads stay private: review reads them from storage instead of
fetching the URL (review_entry_bytes), and review approve imports the
approved bytes as the artifact the URL then serves.
A bot entry’s program is uploaded the same way, to
PUT /uploads/<blake3>.program, as the Shortcut’s canonical JSON
(tilo_automation::program), at most 256 KiB. The registry refuses bytes that
are not exactly the canonical encoding (422), because the entry signs their
hash and a room stores the same bytes. The answer’s url is
https://apps.tilo.so/blobs/<blake3>, which serves the program once review
approves a version that binds it. Pruning keeps a program blob while any
retained entry binds it.
tilo-app-package bot-entry builds a bot entry from a program file in any
formatting. It re-encodes the Shortcut canonically, derives the apps,
triggers and appearance the program needs, refuses --connector <id>@<min version> lists that differ from the connectors its triggers use,
and with --upload sends the canonical bytes first:
tilo-app-package bot-entry \
--identity /run/secrets/app-publisher.json \
--package-id so.tilo.bots.echo --version 1.0.0 \
--action add --upload echo.json > request.json
A bot version is {"version", "released_at_unix", "bot": {"program_url", "program_blake3_hex", "program_size_bytes", "requires": {"connectors": [{"package_id", "min_version"}], "apps", "appearance", "triggers"}}}.
requires must equal what the program uses: the registry
checks it on submission and clients check it again on install. A bot is
never installed as an app; it goes into a room through that room’s
install-bot action.
A newer request for a package replaces the one waiting for review and must
keep every version it lists, so a publisher releasing again before review
builds on its waiting entry. GET /entries/<package id>/pending returns that
request to its publisher only: the public key in Tilo-Publisher, the Unix
time in Tilo-Signed-At, and in Tilo-Signature an Ed25519 signature over
"tilo-registry-pending-read-v1\0", the package id, a NUL and the time in
decimal (registry_types::pending), within five minutes of the registry’s
clock. Any other caller, and a publisher with nothing waiting, gets 404.
Presentation metadata is a signed part of the entry. Localized fields use one
consistent language-tag map, and every non-empty localized map must contain an
English (en) fallback. Language tags are canonical BCP 47 spellings (for
example en, de, or zh-Hant-TW); empty values, malformed tags such as
en_US, and unsorted or duplicate supported-language lists are rejected.
supported_languages describes languages the app itself supports, not merely
the locales supplied for its store listing. Example authoring input:
{
"name": {"en": "Example", "de": "Beispiel"},
"description": {
"en": "A focused example app for Tilo OS.",
"de": "Eine fokussierte Beispiel-App für Tilo OS."
},
"tagline": {"en": "Learn by example", "de": "Am Beispiel lernen"},
"images": {
"en": [
{
"kind": "icon",
"url": "https://apps.tilo.so/images/example-icon.png",
"alt": "Example app icon"
},
{
"kind": "screenshot",
"url": "https://apps.tilo.so/images/example-main.png",
"alt": "Example app main screen"
}
]
},
"supported_languages": ["de", "en"],
"source_url": "https://code.tilo.so/example/example",
"homepage": "https://example.invalid"
}
Clients resolve exact locale, then its base language, then English. Images use
the same fallback rule and distinguish icon, screenshot, and banner.
Each signed version binds the package root, exact final-file BLAKE3 and size,
platform family, architecture, minimum ISA and runtime ABI. The schema can add
future platform families without changing the signed entry shape. Five
families are accepted (registry_types::target):
platform |
architecture |
isa |
runtime |
|---|---|---|---|
tilo-os |
x86_64, aarch64 |
x86_64, x86_64-v2…v4, aarch64, aarch64-v8.2/v8.4/v9 |
optional, tilo-runtime/<version> |
linux |
x86_64, aarch64 |
the feature level it was compiled for, non-empty | required, the linked tilo-runtime/<version> |
windows |
x86_64, aarch64 |
the feature level it was compiled for, non-empty | required, the linked tilo-runtime/<version> |
macos |
x86_64, aarch64 |
the feature level it was compiled for, non-empty | required, the linked tilo-runtime/<version> |
android |
x86_64, aarch64 |
the feature level it was compiled for, non-empty | required, the linked tilo-runtime/<version> |
A native release must publish a distinct .tapp for both architectures. Pass
each tapp’s target with tilo-app-package entry --arch. Linux, Windows,
macOS and Android versions sit beside Tilo OS versions in the same entry: version identity
is (version, platform, architecture), and a Tilo OS client installs only
tilo-os versions.
Linux, Windows, macOS and Android. A linux build is for Linux desktops
other than Tilo OS. Those systems ship no Tilo toolkit, so the package is one
self-contained executable with the toolkit linked in; for android that is an
.apk (or an .aab for store upload, registry_types::ANDROID_PACKAGE_EXTENSIONS). Its signed [target]
names the executable as module (the GUI entrypoint must name the same path)
and the linked release as runtime, equal to [package].runtime
(tilo_app_package::desktop::desktop_manifest). Such a build may be up to
MAX_DESKTOP_APP_TAPP_BYTES (64 MiB), a distribution policy rather than a
design target; tilo-app-package entry --platform linux|windows|macos|android
binds it.
6. Moderator workflow
Publisher requests are stored in pending/, one retry-safe slot per
package. The owner reviews them locally; there is intentionally no
remotely callable approval endpoint.
tilo-registry-server review list --json
tilo-registry-server review show so.tilo.example --json
tilo-registry-server review approve so.tilo.example \
--review-id <review_id-from-show> \
--reviewer alice --reason "capabilities and core journeys verified"
tilo-registry-server review reject so.tilo.example \
--review-id <review_id-from-show> \
--reviewer alice --reason "misleading subscription disclosure"
show and approve fetch every declared package version with strict
size and time bounds, refuse redirects and private-network destinations,
re-derive the content-addressed identity and publisher signature, and
compare the signed manifest’s app id and version with the submission.
Their output includes filesystem, network, portal, and service requests.
Approval moves the exact signed metadata into snapshot staging; a moderator
cannot quietly rewrite a publisher’s submission.
A bot entry has no package to fetch: its program was uploaded to this
registry before submission, and submit already refused it unless the bytes
hash and size-match, parse as a canonical, structurally valid program, and
need exactly the apps, connectors, triggers and appearance the version lists.
show reads the program from storage and prints its name, step count and
those needs; approve re-checks them and publishes the program at
/blobs/<blake3>. An id never changes kind: an app cannot be updated into a
bot or the reverse (409).
show --json includes a review_id for the entire signed pending request,
including its receipt time and both signatures. Pass it to either decision to
refuse a request replaced after inspection, before fetching packages or changing
registry state. Approval also rechecks the exact candidate under the registry
lock; rejection does the same and retains the reviewed bytes and literal feedback.
Rejecting an invalid request does not require its signature to become valid.
The flag is optional for existing CLI callers; agents should preserve and pass it.
Publish the next bounded-lifetime snapshot from an offline signing environment:
chmod 0600 /secure/registry-owner.seed
tilo-registry-server sign-snapshot \
--owner-key-file /secure/registry-owner.seed \
--valid-for-seconds 86400
Production does not put the root key in a recurring job. During bootstrap (or manual rotation), the offline root authorizes one short-lived channel key and binds it to the exact publisher policy, maximum snapshot lifetime, and entry count. Installing a higher-generation authorization immediately revokes the old online key at the registry; certificates expire after at most 31 days.
tilo-registry-server authorize-channel \
--owner-key-file /secure/registry-owner.seed \
--channel-pubkey "$CHANNEL_PUBKEY" \
--publisher-pubkey "$FIRST_PARTY_PUBLISHER" \
--generation 1
Each recurring promotion has two security boundaries. A networked collector has no signing key and writes exact tapp bytes under their BLAKE3 names. A separate networkless signer receives only the delegated key; it re-verifies the publisher request, live allowlist, staged-byte digest, tapp root/signature, manifest identity, target policy, delegation scope, predecessor, sequence and expiry before approving or signing anything:
tilo-registry-server collect-first-party \
--publisher-pubkey "$FIRST_PARTY_PUBLISHER" --max-items 50
tilo-registry-server publish-staged-first-party \
--channel-key-file /run/secrets/registry-channel/seed \
--reviewer registry-promoter \
--reason automated-first-party-promotion \
--valid-for-seconds 86400
# A separate networkless schedule refreshes expiry only when no batch is staged.
tilo-registry-server sign-delegated-snapshot \
--channel-key-file /run/secrets/registry-channel/seed \
--valid-for-seconds 86400
The collector selects pending runtime providers before apps within the same item bound, so a full page of deferred apps cannot keep its provider beyond the cutoff. It reads a version from its own content-addressed store when it already holds the bytes the publisher signed, and fetches only new versions. A submission it cannot collect completely stays pending and out of the staged batch; the others are staged, and the collector exits non-zero listing it.
The signer checks every collected request and package before selecting complete
requests whose new native versions have exact runtime providers in the resulting
catalogue. A missing runtime defers that whole request, preserving its pending
metadata, signatures and referenced package bytes. Independently installable
requests and runtime providers can still publish. An entirely deferred batch
reports status = "deferred" without signing or approving anything; a partial
promotion reports its promoted and deferred counts. Signature, authority, byte,
retention and recovery failures remain errors. The durable promotion journal and
staged inventory contain only the selected requests, so crash recovery repeats
the same verified transition. The next collector reconsiders pending requests.
To resolve a deferred request, publish its exact verified runtime or have the original publisher replace its signed pending entry through normal publication. Publisher reconciliation may retire an unpublished missing-runtime version only after four newer offered releases of the same target and channel remain; it does not rewrite package identities or remove published rollback history.
The store keeps each staged package once, as the payload blobs and thin form it
serves for thin installs plus a record of the package root, and rebuilds and
re-hashes the exact bytes on read. After every publish the signer deletes the
staged packages, thin packages and blobs that no published, quarantined,
pending or staged entry, retained history snapshot or served version index
references, such as the versions an entry dropped. tilo-registry-server prune-storage does the same on demand; with --dry-run it only reports what
it would delete.
Version retention
Publishers bound their entries: each signed entry keeps the newest versions of
every target (platform and architecture) and channel (release or pre-release),
20 by default (registry_types::retention), and drops the rest when the next
version is signed. The registry accepts a dropped version only when at least
MIN_RETAINED_PER_CHANNEL (4) newer versions of its target and channel stay,
or when the entry is at its 128-version limit and a newer version supersedes
it, so retention never removes a target’s current version or its recent
rollback targets.
A dropped version leaves the catalogue through an ordinary signed snapshot:
the compact snapshot and version index stop listing it, app rollback stops
offering it, and the rollback, fork and freeze checks are unchanged. Its
artifacts stay while a retained history snapshot (which rollback-snapshot
may restore) or the previous version index (which a client one snapshot behind
may still fetch) lists it; the prune after that deletes them.
Retention is also what lets a device open an app without fetching a runtime:
a first open takes the newest retained version whose runtime build the device
already has (app_cli::first_open_offer). With the default of 20, an image or
per-user runtime from within the last 20 releases of an app opens it with no
runtime download; an older one downloads the runtime the newest version names.
On the device, a downloaded runtime build lives in
$XDG_DATA_HOME/tilo-runtime/<version>/ only while something needs it
(runtime_bundle::gc). After every tilo-install install, update and
uninstall, and on tilo-install gc, a build is removed unless an installed
package (the user’s or the image’s) names it as its runtime or among its
optional-runtimes, it is pinned (tilo-install runtime pin, or installed
with --pinned), it was installed within the last hour (an app’s first open
installs its runtime and then the app, in two steps), or a running app has it
mounted. A build leaves its version’s directory in one rename before any file
is deleted, so a collection stopped halfway never leaves a build a launch
would pick. Nothing runs on a timer.
A launch holds what it runs on from before it looks at it until its sandbox
exits: a shared flock on .launches/<version> in the runtime root for each
per-user runtime build, and on leases/<package id> in the app store for its
payload tree. Removal takes the same file exclusively without waiting, so an
app opened while it is being updated keeps the build it chose, payload and
runtime, and the collection after the launch ends takes them. A launcher that
dies releases its holds with its descriptors. The app host’s own installs and
removals run the same collection as tilo-install, which also removes the
staging a crashed install left in the store’s tmp/.
prune-storage --dry-run --keep-releases N --keep-prereleases M lists the
published versions that policy drops and what pruning deletes once they are
gone, without changing anything.
Both seed files are exactly 32 secret bytes encoded as 64 hex characters. The root seed exists only in the bootstrap/manual authorization environment. The serving process receives neither private key. The collector cannot forge a stage because the signer treats its JSON as untrusted and independently checks the content-addressed bytes. The signer has no network path from which to fetch or substitute content.
Every successful signing also retains the exact signed JSON under
snapshots/<sequence>-<hash>.json before advancing published-snapshot.json.
These immutable files are the channel history and share the data directory’s
backup policy. Roll back catalogue contents by publishing a new, higher
sequence from one of them:
tilo-registry-server rollback-snapshot \
--to-sequence 41 \
--owner-key-file /secure/registry-owner.seed \
--valid-for-seconds 86400
The command does not replay sequence 41. It signs sequence current + 1, links
it to the current snapshot, uses the current publisher allowlist, and refuses
to restore entries from a publisher that has since been removed. Client
anti-rollback checkpoints therefore remain monotonic.
Bounded snapshot history
Clients never read the history. Native and browser clients fetch only the
current snapshot from /entries and keep a checkpoint of the last sequence
and hash they accepted: they refuse a lower sequence (rollback), another
snapshot at the same sequence (fork), and anything expired (freeze, bounded
by the snapshot lifetime). These checks need nothing but the current
snapshot, so pruning history cannot weaken them.
The history serves the registry itself: rollback-snapshot restores from it,
and its members stop the channel from signing a sequence twice. Each member
is the whole catalogue, so the history is kept to the newest
SNAPSHOT_HISTORY_RETAINED (16) members. Every signing command, the publisher
and prune-storage compact it under the registry lock:
- The members to prune, and the oldest one kept, must form one hash chain from the previous compaction record (or the first member). A broken chain stops compaction without deleting anything.
snapshots/compacted.jsonfirst records the highest pruned sequence and its hash. The oldest member kept signs that hash as its predecessor, so the signed chain still commits to every pruned snapshot, and the record can be checked against it.- The registry refuses to archive or publish any sequence at or below the
record, and refuses to restart the channel at sequence 1 when the
published pointer is lost. Restore
published-snapshot.jsonfrom a backup instead.
A client last synced at a sequence that has since been pruned still verifies the current snapshot: its sequence is higher and its signature, freshness and publisher checks hold on their own. A replay of any older snapshot, pruned or not, stays below the checkpoint and is refused. Snapshots older than the retained window can only be restored from backups.
Compact snapshots and version indexes
Every entry keeps up to 128 versions, and every client fetches the snapshot
at least daily, so the full snapshot grows with each release. Each signing
command therefore also signs a compact form (protocol 4,
registry_types::compact) with the same key, owner or channel:
- Per entry it keeps only the newest release and the newest pre-release of
each exact target (
newest_versions), in their original order. Any client’s newest installable version on either channel is among them. - Per entry it signs the BLAKE3 hash and size of a version index,
version-indexes/<blake3>.jsonserved at/indexes/<blake3>.json: the complete publisher-signed entry with every retained version. So the signed snapshot still commits to every older version. - It signs
snapshot_hash_hex, the hash of the full snapshot at the same sequence. That hash is the snapshot’s identity: the next snapshot names it as predecessor and clients checkpoint it whichever form they fetched, so rollback, fork and freeze checks are unchanged and a client may switch forms freely.
Clients fetch an index only to install or roll back to an older version
(app install <app> --version <v>, app rollback <app> [--to <v>]).
verify_version_index checks its size and hash against the signed values,
that it is the same package, that the publisher’s signature over the
complete entry verifies, and that the entry reduces exactly to the one in
the snapshot. A compact snapshot cannot carry publisher signatures, which
cover complete entries; its own signature, the index check and the package’s
signature on install take their place.
The server serves the compact form only while it verifies and is the form of
the current full snapshot; otherwise ?format=compact gets the full one.
Publishing keeps the version indexes of the current and the previous compact
snapshot, so a client one snapshot behind can still fetch its index.
Rollout. /entries without a query keeps serving the full snapshot, so
released clients work unchanged. The native client (Store and app) and the
browser client ask for ?format=compact and accept either form, also from
older registries. Once released images carry those clients, the default can
change to compact; until then the full form stays the default and remains
available.
For urgent removal, review quarantine immediately moves a public entry
out of search while keeping its bytes and decision record recoverable.
review restore re-checks the signature and current allowlist before
making it visible again.
TLS
TLS is deliberately out of scope for the server binary. All security guarantees on the wire come from the Ed25519 signatures covering entries and change requests — clients verify these against the pinned owner pubkey regardless of whether the transport is HTTPS.
For deployments, use a TLS-terminating reverse proxy in front:
- Rotate certificates without restarting the registry.
- Rate-limit and cache GETs at the edge.
- Serve the
.tapppayloads themselves — the entries just point at HTTPS URLs; the bytes can live on any CDN / object store.
nginx example
server {
listen 443 ssl http2;
server_name apps.acme.example;
ssl_certificate /etc/letsencrypt/live/apps.acme.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/apps.acme.example/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8443;
proxy_set_header Host $host;
}
location /packages/ {
alias /srv/tapp/; # your .tapp bytes served straight
autoindex off;
}
}
Edge cache for apps.tilo.so
Every content-addressed route above, and the generic-package files under
/artifacts/<owner>/generic/... the release pipeline publishes, is immutable
by construction: its URL names its bytes. The Tilo deployment puts Cloudflare
in front of Caddy, whose configuration lives with the deployment, not in this
repository. For the edge to keep these objects:
- One
Cache-Control. Forgejo’s package route addsCache-Control: private, max-age=300to every file; the proxied response must carry onlypublic, max-age=31536000, immutable. In Caddy, inside thereverse_proxyof the/artifacts/*route that proxies to Forgejo,header_down Cache-Control "public, max-age=31536000, immutable"(setting replaces Forgejo’s value), and no site-levelheader Cache-Controlon that route: one applied before the proxy is added to, not replaced by, the upstream header, which is how two arrive today. The registry server already sends exactly that header for/blobs/*,/thin/*,/indexes/*and its own/artifacts/<blake3>.tapp[.zst]. - Eligible for cache.
/blobs/<blake3>has no file extension and.tappis not in Cloudflare’s default cached set, so a Cache Rule must make/artifacts/*,/blobs/*,/thin/*and/indexes/*“Eligible for cache” with “Use cache-control header if present”. Leave/entries*,/registryand every non-GET out of it: the snapshot revalidates by ETag. - No transformation. Twins are
application/zstd; Cloudflare’s own compression must not touch them (it does not by default).
Check it with tools/ci/probe-registry-edge-cache.sh <tapp-url> <blob-url>:
it fetches each URL twice and fails unless the second answer is a
cf-cache-status: HIT with exactly one Cache-Control.
Caddy example
apps.acme.example {
reverse_proxy /registry* /entries* /health 127.0.0.1:8443
file_server /packages/* {
root /srv/tapp
}
}
Backup and restore
Everything the server owns is under the data directory:
data-dir/
handshake.toml # server identity — owner pubkey + name / description / base_url
publishers.toml # allowlist
entries/
<pkg-id>.json # approved public entries only
pending/
<pkg-id>.json # signed requests awaiting review
reviews/
<review-id>.json # immutable moderator audit records
quarantined/
<pkg-id>.json # recoverable entries hidden from clients
snapshots/
<sequence>-<hash>.json # newest signed channel history, bounded
compacted.json # highest pruned sequence and its hash
published-snapshot.json # atomic current channel pointer
published-compact-snapshot.json # compact form of the current snapshot
version-indexes/
<blake3>.json # complete signed entries the compact form names
payload-blobs/
<blake3> # payload blobs of reviewed packages, and approved bot programs
thin-packages/
<root>.tapp # thin form of each reviewed package
artifacts/
<blake3>.tapp # full packages hosted with import-artifact
uploads/
<blake3>.tapp # publisher package uploads awaiting review; private
<blake3>.program # publisher bot programs awaiting review; private
All writes are atomic-rename (.tmp → real path), so
tar -czf backup.tgz data-dir/ while the server is running is
safe — the worst case is that you capture a subset of
in-flight changes; nothing gets torn.
For a restore, drop the same tarball back in place and start
tilo-registry-server serve. There’s no daemon-side state
outside the data-dir — the server is stateless across restarts.
Entry format cut-over
Entries signed under the earlier domain, before kind and version
artifacts, do not verify and are not read. A registry moves to the
current form by archiving its data directory, starting empty, and
republishing every app through the normal release workflow, then
publishing a signed snapshot. Devices whose app installer predates the
change cannot read the new catalogue until their OS update brings one that
can.
Moderation policy and operations
- Ban a publisher with
remove-publisher. Pending work from that key can no longer be approved, and restoring quarantined entries checks the current allowlist. - Quarantine an entry through the review CLI, never by deleting files
by hand. This removes it from
/entries, keeps the exact publisher-signed entry recoverable, and records who acted and why. - Review the package, not its name or screenshot. Approval verifies immutable package bytes and surfaces sandbox capabilities. Operators should also publish and apply rules for privacy, billing disclosure, impersonation, illegal content, and abusive behavior.
- Keep decisions explainable. Reviewer identity, reason, time, exact
submission, and decision are durable JSON records under
reviews/. - Federation snapshots use the versioned
SignedRegistrySnapshotschema. Clients verify the pinned owner signature, validity window, monotonic durable checkpoint, every publisher signature, and membership in the exact signed publisher policy. Bare entry arrays and TOFU are rejected.
Threat model — what this design does NOT protect against
- Compromised registry owner — the owner can inject any publisher into the allowlist and thus indirectly cause any entry to be accepted. Clients that pin an owner and lose trust must un-pin and re-pin a new owner.
- Compromised publisher key — an attacker with a publisher’s
private key can submit malicious bytes, but cannot replace the
public entry without owner review. Package and registry signatures
stop in-flight substitution; human review is still fallible, so
operators should quarantine suspicious entries and revoke the key.
Publishers should keep signing keys in hardware and rotate on
compromise; the Tilo threshold
identity work (
crates/collab/threshold-identity) is the long-term path to publisher-key resilience. - Denial of service — no rate limiting is built in. Put the
reverse proxy in front, or run
fail2banagainstjournalctl -u tilo-registry-server. All POSTs perform an Ed25519 verification before touching disk, so unsigned junk is cheap to reject.
This page is docs/registry.md in the Tilo repository, at 29fbb80121. Changes to it are made there.