Developer guide

Crux Daemon · 9. Registry and publishing

Crux Daemon developer guide

9. The registry and publishing rail

The distribution model is deliberately thin: a curator-signed JSON index in a git repo, plus content-addressed manifests fetched from wherever the author hosts them. There is no hosted store, no upload endpoint, and no binary the project serves on your behalf.

9.1 End to end

author writes manifest.json
   └─ signs with an Ed25519 passport key
        └─ opens a PR to integrations/community/<id>/<version>/
             └─ CI gate: cargo test -p crux-integrations --test community_packs
                  └─ curator adds an entry to index.json and re-signs the index
                       └─ operator: corecruxctl extensions sync   (verify + cache)
                            └─ operator: corecruxctl extensions list-registry  (review)
                                 └─ operator: corecruxctl extensions install <id>
                                      └─ daemon: re-verify index, fetch manifest,
                                         enforce manifest_sha256, validate signature
                                           └─ operator: POST .../grants  (scope a passport)

Two independent integrity layers, doing different jobs:

LayerBindsWho signs
Manifest signatureThe policy surface — capabilities, network, data access, safetyThe author
manifest_sha256 in the indexThe entire manifest file, byte for byteThe curator

The second closes the gap the first leaves (see §2.4).

9.2 Authoring and signing

Sign with sign_manifest (crates/crux-integrations/src/signing.rs:163), which also fills hashes.manifest. In Rust:

use crux_integrations::{sign_manifest, fingerprint_from_public_key};
use ed25519_dalek::SigningKey;

let key = SigningKey::from_bytes(&seed);              // your 32-byte seed
let fpr = fingerprint_from_public_key(&key.verifying_key());  // "p_<32 hex>"
sign_manifest(&mut manifest, &key, fpr)?;

For a Studio board, POST /v1/studio/pack/build will sign for you when CORECRUXD_STUDIO_SIGNING_KEY_HEX is set (chapter 8).

Keep signature.public_key_hex in the published manifest. The community CI gate needs it — at PR time no operator keyring exists yet, so the gate builds a policy trusting the manifest's own inline key and proves the pack is validly self-signed (tests/community_packs.rs:44). Curator endorsement and operator keyring trust happen later, at install.

9.3 The PR layout

integrations/community/<pack-id>/<version>/manifest.json
integrations/community/<pack-id>/<version>/README.md
integrations/community/<pack-id>/<version>/review.json   # only if dangerous caps

(integrations/README.md)

What the CI gate asserts

community_pack_manifests_are_safe_and_reviewable (tests/community_packs.rs:29) walks the tree recursively and, for every manifest.json (tests/community_packs.rs:68):

AssertionFailure message
manifest.validate() passes against the inline-key policythe library error
publisher_passport_fpr != "cuecrux:first-party"<path> must not claim first-party publisher identity
hashes.manifest present<path> must include hashes.manifest
signature present<path> must include an Ed25519 Passport signature
entry.kind != external_helper<path> uses external_helper; community v1 packs are declarative only
entry.path is a safe relative path<path> has unsafe entry.path '<p>'
README.md exists beside the manifest<dir> must include README.md with setup and safety notes
Dangerous capability ⇒ review.json with maintainer_approval: true and a non-empty rationale<review.json> must set maintainer_approval=true / must include a non-empty rationale

Run it locally before opening the PR:

cargo test -p crux-integrations --test community_packs

The gate proves the pack is well-formed and validly self-signed. A tampered payload still fails here — the signature will not verify, or the hash will not match.

9.4 The curator index

Schema and verification are covered in §4.6. The curator's job is to:

  1. Compute manifest_sha256 — SHA-256 over the exact manifest bytes that will be served at manifest_url.
  2. Add a CommunityExtensionEntry with id, name, version, summary, manifest_url, manifest_sha256, repo_url, kind, trust_tier.
  3. Re-sign the whole index with the curator key (lib.rs:738).
  4. Publish it at a stable HTTPS URL.

The trust_tier the curator assigns is advisory — an operator's local keyring overrides it at install time (lib.rs:681).

9.5 Operator: sync

corecruxctl extensions sync \
  --url https://raw.githubusercontent.com/CueCrux/community-extensions/main/index.json \
  --pubkey-fpr p_curator \
  --pubkey-hex 4c00e9689fc124fbdf7d4afb2d43632cf529eae10234c17f08ecb3a2f569bedf \
  --data-dir /srv/crux/data

(corecruxctl/src/main.rs:1356, corecruxctl/src/extensions.rs:73)

What it does, in order:

  1. Rejects a --pubkey-hex that is not exactly 64 lowercase hex characters: public_key_hex must be 64 lowercase hex chars.
  2. HTTPS GET with a 30-second global timeout, capped at 1 MiB (REGISTRY_INDEX_DOWNLOAD_LIMIT_BYTES, extensions.rs:61).
  3. Parses, then verifies the signature against a policy holding only the supplied curator key.
  4. Writes the verified bytes atomically to <data-dir>/extensions/registry/index.json.

On a verification failure the cache is not written at all. That is pinned by sync_rejects_index_signed_by_wrong_key (extensions.rs:282), which asserts the cache path does not exist after a bad sync. The daemon can never be serving a half-trusted index.

Output lists curator, timestamp, entry count, the cache path, and one line per entry with id, version, kind and trust tier (corecruxctl/src/main.rs:4089).

9.6 Operator: review

corecruxctl extensions list-registry --data-dir /srv/crux/data

Prints each entry in full — id, kind, trust tier, summary, repo URL, manifest URL and sha256 (corecruxctl/src/main.rs:4115).

This subcommand reads the cached file directly and does not re-verify the signature (extensions.rs:136) — the bytes were verified when sync wrote them. To browse a verified view over HTTP, use the daemon route, which re-verifies on every call:

curl -s http://127.0.0.1:14800/v1/extensions/registry \
  -H "Authorization: Bearer $CRUX_AGENT_TOKEN"
{
  "schema": "crux.extensions.registry_list.v1",
  "curator_passport_fpr": "p_curator",
  "updated_at_unix_ms": 1753440000000,
  "entries": [
    { "id": "ext.example.quote", "...": "...",
      "installed": true, "installed_version": "0.1.0" }
  ]
}

Each row is joined against the installed set, so a console can render "installed / update available" without a second round trip (http/extensions.rs:250). Scope: admin:read. No network is touched — it is a read-only twin of the install path over the same cache (http/extensions.rs:221).

On a fresh daemon this returns 404 with a detail that names the fix: registry index read failed: <io error> (run \corecruxctl extensions sync\ to populate the cached registry index) (http/extensions.rs:237).

9.7 Operator: install

corecruxctl extensions install ext.example.quote \
  --http-url http://127.0.0.1:14800 \
  --token "$CRUX_AGENT_TOKEN"

Or directly:

curl -s -X POST http://127.0.0.1:14800/v1/extensions/install-from-registry \
  -H "Authorization: Bearer $CRUX_AGENT_TOKEN" \
  -H "X-Corecrux-Passport-Id: p_operator" \
  -H 'Content-Type: application/json' \
  -d '{"id":"ext.example.quote"}'

The CLI never reads the index itself — the daemon does the verifying (corecruxctl/src/extensions.rs:21). Daemon base URL resolves --http-urlCORECRUXD_HTTP_URLhttp://127.0.0.1:14800; the token resolves --tokenCRUX_AGENT_TOKEN (extensions.rs:156).

install_from_registry (http/extensions.rs:284) runs this sequence:

#StepFailure
1Scopes admin:read + facts:write403
2Load and re-verify the cached index against the local keyring404 read failure, 400 decode failure, 403 registry index signature verification failed: <err>
3Find the entry by id404 extension '<id>' not found in registry index
4Check manifest_url is HTTPS, or loopback HTTP400 manifest_url must be https://, or loopback http:// for local tests
5Fetch it — 30 s timeout, 2 MiB cap502 on transport or non-2xx, 413 manifest exceeds the 2097152-byte cap
6SHA-256 must equal the curator's manifest_sha256409 manifest_sha256 mismatch for '<id>': registry=<a>, downloaded=<b>
7Decode as a manifest502 manifest JSON decode failed: <err>
8id and version must match the index entry409 registry entry mismatch: expected <a>@<b>, manifest is <c>@<d>
9Delegate to the same installer as /v1/extensions/register — signature validated against the keyring400 with the library error

The index_path body field lets an operator point at a private mirror. Relative paths resolve under the daemon's data dir; absolute paths are taken as-is (http/extensions.rs:373).

Success is 201:

{
  "schema": "crux.extensions.registry_install.v1",
  "registry_entry": { },
  "manifest_sha256": "9f86…",
  "installed": { "manifest": { }, "manifest_hash": "blake3:…", "trust_tier": "community_reviewed", "installed_at_unix_ms": 1753440000000, "installed_by_passport": "p_operator" }
}

The CLI closes with the honest next step: grants: none yet — POST /v1/extensions/{id}/grants to scope a passport (corecruxctl/src/main.rs:4156).

9.8 Installing without a registry

POST /v1/extensions/register takes a manifest inline:

curl -s -X POST http://127.0.0.1:14800/v1/extensions/register \
  -H "Authorization: Bearer $CRUX_AGENT_TOKEN" \
  -H "X-Corecrux-Passport-Id: p_operator" \
  -H 'Content-Type: application/json' \
  -d '{"manifest": '"$(cat manifest.json)"'}'

Scopes admin:read + facts:write (http/extensions.rs:121). The signing key must already be in the trusted keyring, or CORECRUXD_EXTENSIONS_ALLOW_UNSIGNED must be on.

Extension id rules are stricter than manifest id rules: lowercase ASCII, digits, ., -, _ only, at most 128 characters (extension_registry.rs:80). A violation gives manifest id '<id>' contains invalid characters; expected lowercase alphanumerics + . - _.

Re-installing an existing id is a 409 extension id already installed (http/extensions.rs:201). Upgrades are uninstall then install; the error text says so: extension '<id>' already installed (use DELETE first to replace).

9.9 The second rail: the Studio template library

Everything above distributes code. The Studio template library distributes dashboards, over the same rails, with one extra hop at the end: the daemon applies the artefact itself instead of handing you a manifest to grant.

author builds a board in Studio
   └─ POST /v1/studio/pack/build   (manifest + both hashes, signed if a key is set)
        └─ publishes the pack JSON at a stable HTTPS URL
             └─ curator adds a row (pack_url + pack_sha256) to a
                crux.studio.index.v1 index and signs the whole index
                  └─ operator: corecruxctl studio sync         (verify + cache)
                       └─ operator: corecruxctl studio list-library   (review)
                            └─ operator: corecruxctl studio install <id>
                               or the console Library panel
                                 └─ daemon: re-verify index, fetch pack,
                                    pin pack_sha256, REQUIRE the pack be signed,
                                    write console facts with provenance

Chapter 8 covers the index schema, the install invariants and the response. This section is the operator-facing command rail.

Sync

corecruxctl studio sync \
  --url https://example.com/studio-library/index.json \
  --pubkey-fpr p_curator_studio \
  --pubkey-hex 4c00e9689fc124fbdf7d4afb2d43632cf529eae10234c17f08ecb3a2f569bedf \
  --data-dir /srv/crux/data

(corecruxctl/src/main.rs:1401, corecruxctl/src/studio.rs:73)

A deliberate mirror of extensions sync — same fetch/verify/cache ceremony, same 1 MiB index cap, same atomic write, same public_key_hex must be 64 lowercase hex chars guard. Only the schema and the cache directory differ (corecruxctl/src/studio.rs:10).

The cache lands at <data-dir>/studio/library/index.json (corecruxctl/src/studio.rs:66). A verify failure leaves any previously cached index untouched — the same fail-closed property as the extensions rail.

Because StudioLibraryIndex::verify runs shape validation after the signature check, sync also rejects a validly-signed index carrying a malformed row: a non-HTTPS pack_url, a bad pack_sha256, a non-semver version, a duplicate id. The error names the offending entry.

Output lists curator, timestamp, entry count, the cache path, then one line per entry with id, version, kind and advisory tier (corecruxctl/src/main.rs:4169).

Review

corecruxctl studio list-library --data-dir /srv/crux/data

Prints each row in full — id, kind, publisher, summary, tags, advisory tier, preview, repo, pack_url and pack_sha256 (corecruxctl/src/main.rs:4188). Like its extensions twin, it reads the cached file and does not re-verify; the bytes were verified when sync wrote them. The tier line is printed with its honesty suffix: (advisory — enforced by the catalog server, not the daemon).

For a re-verified view over HTTP, use GET /v1/studio/library (§8.9), which also joins against what is already installed.

Install

corecruxctl studio install studio.ops-overview \
  --http-url http://127.0.0.1:14800 \
  --token "$CRUX_AGENT_TOKEN"

(corecruxctl/src/studio.rs:189) The CLI never reads the index itself. The daemon re-verifies the index signature, pins pack_sha256, and requires the pack to be signed (corecruxctl/src/studio.rs:25). URL and token precedence match the extensions CLI: --http-urlCORECRUXD_HTTP_URLhttp://127.0.0.1:14800, and --tokenCRUX_AGENT_TOKEN (corecruxctl/src/studio.rs:158).

--index-path forwards a daemon-side override for a private mirror; relative paths resolve under the daemon's data dir (corecruxctl/src/main.rs:1428).

Output names every artefact written and every id that had to be remapped (corecruxctl/src/main.rs:4232):

Installed studio.ops-overview from the cached Studio library index.
  schema:          crux.studio.library_install.v1
  version:         0.1.0
  pack_sha256:     9f86…
  signed:          true
  required_tier:   pro (advisory)
  + board console:tileboard:studio.ops-overview (key doc)
  + page console:page:ws-ops-facts-2 (key def)
  ~ page id remapped ws-ops-facts -> ws-ops-facts-2 (existing artifact preserved)

Two indexes, one keyring

Community extensionsStudio library
Schemacrux.community-extensions.index.v1crux.studio.index.v1
Cache<data_dir>/extensions/registry/index.json<data_dir>/studio/library/index.json
Per-row pinmanifest_sha256pack_sha256
Browse routeGET /v1/extensions/registry (admin:read)GET /v1/studio/library (query:read)
Install routePOST /v1/extensions/install-from-registryPOST /v1/studio/library/{id}/install
CLI`corecruxctl extensions sync\list-registry\install``corecruxctl studio sync\list-library\install`
Unsigned dev bypassCORECRUXD_EXTENSIONS_ALLOW_UNSIGNEDCORECRUXD_STUDIO_ALLOW_UNSIGNED
Shape validationat point of useinside verify(), at the trust boundary
Result of installa manifest record; still needs a grantconsole facts, written immediately

Both verify against the same <data_dir>/extensions/trusted-keys.json (studio_library.rs:741), so one POST /v1/extensions/keys call admits a curator to both rails. Give the two curators separate fingerprints unless they really are the same authority.

Note the asymmetry in the last row: an extension install is inert until granted; a library install writes artefacts straight away. That is why the library install is require-signed by default while the extension install is not.

9.10 The full route reference

RouteMethodScopesHandler
/v1/extensionsGETadmin:readextensions.rs:76
/v1/extensions/registerPOSTadmin:read, facts:writeextensions.rs:121
/v1/extensions/install-from-registryPOSTadmin:read, facts:writeextensions.rs:284
/v1/extensions/registryGETadmin:readextensions.rs:228
/v1/extensions/keysGETadmin:readextensions.rs:503
/v1/extensions/keysPOSTadmin:read, facts:writeextensions.rs:522
/v1/extensions/keys/{passport_fpr}DELETEadmin:read, facts:writeextensions.rs:870
/v1/extensions/{id}GETadmin:readextensions.rs:95
/v1/extensions/{id}DELETEadmin:read, facts:writeextensions.rs:464
/v1/extensions/{id}/grantsGETadmin:readextensions.rs:587
/v1/extensions/{id}/grantsPOSTadmin:read, facts:writeextensions.rs:610
/v1/extensions/{id}/grants/{passport_fpr}DELETEadmin:read, facts:writeextensions.rs:837
/v1/extensions/{id}/tools/{tool_name}/invokePOSTadmin:read, facts:writeextensions.rs:686
/v1/studio/libraryGETquery:readstudio_library.rs:116
/v1/studio/library/{id}/installPOSTadmin:read, facts:writestudio_library.rs:287

Router registrations run from http/mod.rs:1061 to http/mod.rs:1117. Note the ordering comment there: /v1/extensions/registry is a static segment, so it is registered ahead of /v1/extensions/{id} and wins the match.

Uninstall is a tombstone write, not a file delete (extension_registry.rs:222). It returns 204, or 404 extension '<id>' not found. It does not cascade to grants — revoke those separately if you intend to reinstall a different build under the same id.

Ground truth