# Punktfunk plugin index The signed catalog of plugins that the Punktfunk plugin store shows you. A Punktfunk host fetches two files, served directly from this repository over Gitea's anonymous raw endpoint: ``` https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json.sig ``` The first is the catalog; the second is an ed25519 signature over the exact bytes of the first. The host pins the index URL and derives the signature URL by appending `.sig`. The host verifies the signature against a public key compiled into the host binary, and only then parses the catalog. An index that fails the signature check is discarded whole — the host keeps whatever catalog it already had rather than trusting an unverified one. This repository is the source of truth for that catalog. Everything in it exists to make one sentence true: **a plugin version becomes installable from the official store only after a human has reviewed that exact version.** --- ## The trust model ### The pin is the gate A catalog entry does not point at a package. It points at **one exact version and that version's tarball integrity hash**: ```json "pkg": "@punktfunk/plugin-rom-manager", "version": "0.3.0", "integrity": "sha512-b+CLy5+N/xnqVErqufbIs90gMcXmRvbH4fok9XE9nySGnFvWMBNQvZpVLjc6VDy8hKpsD5Zh1EGle4hn3B5HJQ==" ``` There are no version ranges. There is no `latest`. A plugin author publishing a new version to the registry changes nothing for users — the store still offers the pinned version, and the host still installs exactly the bytes whose hash is written here. The new version becomes installable only when someone opens a pull request against this repository, a reviewer works the checklist, and the change is merged and re-signed. That is what "verified" means on the store badge. Not "we trust this author" — *we read this build*. The `integrity` hash makes the pin enforceable rather than advisory. Even if the registry were compromised and served different bytes for version 0.3.0, the hash would not match and the host would refuse the install. ### What the signature does and does not do The signature proves the catalog came from unom and was not altered in transit or at rest on whatever is serving it. That matters especially here, where the serving infrastructure is a git host: anyone who could push to this repository, or tamper with what Gitea serves, still could not produce a catalog any host would accept without the signing key. It says nothing about the plugins themselves — that is the review's job. Two independent gates: | Gate | Protects against | Enforced by | | --- | --- | --- | | ed25519 signature | a tampered or spoofed catalog | the host, before parsing | | pinned version + integrity | a tampered or unreviewed *plugin* | the host, at install | | human review | malicious or careless *code* | the pull request checklist | ### The badge is unom's alone The "verified" badge means unom reviewed that specific version. It is a claim about work we did, so it **never transfers**. An operator who adds a third-party source gets that source's plugins listed without the badge, no matter what that source's own index says about itself. A third-party index cannot mark its entries as unom-verified, because the badge is not a field in this format — it is a property of *which source the entry came from*, decided host-side. --- ## Repository layout ``` v1/index.json the catalog (the only file that matters) v1/index.json.sig written by CI, committed, served as-is tools/validate.ts shape rules + live registry cross-check tools/sign.ts sign index.json with the private key tools/verify.ts verify a signature against a public key tools/keygen.ts generate a new signing keypair tools/keys.ts shared ed25519 key encoding helpers .gitea/workflows/validate.yml PR gate: validate only, no secrets .gitea/workflows/publish.yml main: validate, sign, verify, commit .sig .gitea/pull_request_template.md the review checklist ``` Requires [Bun](https://bun.sh). No dependencies beyond Node builtins. ```sh bun run validate # check the catalog (hits the registry) bun run validate -- --offline # skip the network, shape rules only bun run validate -- --fix # rewrite in canonical formatting bun run verify # check the signature against the host-pinned key ``` `validate` is the thing that fails a bad pull request. It checks every field rule the host enforces, and then checks each entry against its live registry: the pinned version must exist, and its `dist.integrity` must equal the pinned `integrity`, byte for byte. It is deliberately strict about unknown keys. The host ignores fields it does not recognise, so a slip like `min_host` instead of `minHost` would parse "successfully" and silently lose its meaning — the entry would ship without a host-version floor and land on machines too old to run it. The validator treats that as an error and suggests the right spelling. --- ## The format ```jsonc { "schema": 1, "name": "unom official", "generated": "2026-07-20T18:00:00Z", "plugins": [ { "id": "rom-manager", // ^[a-z][a-z0-9-]*$, <=64, unique in this index "pkg": "@punktfunk/plugin-rom-manager", // MUST be scoped: @scope/name "registry": "https://git.unom.io/api/packages/unom/npm/", // https, trailing slash "title": "ROM Manager", // 1-64 chars "description": "...", // <=280 chars "icon": "gamepad-2", // lucide icon name, [a-z0-9-]{1,48} "author": "unom", // <=64 chars "homepage": "https://...", // https only "license": "MIT OR Apache-2.0", "version": "0.3.0", // EXACT semver, never a range "integrity": "sha512-...", // the registry's dist.integrity, verbatim "verification": { "reviewedAt": "2026-07-20" }, "minHost": "0.15.0", // optional, exact semver "platforms": ["linux", "windows"] // optional subset of linux|windows|macos } ], "security": [ { "pkg": "@scope/plugin-name", "versions": "<0.3.2", // a Rust semver::VersionReq "reason": "Short description of the problem.", "url": "https://..." // optional advisory link } ] } ``` Keys are **camelCase** (`minHost`, `reviewedAt`). Omitting `platforms`, or giving an empty array, means all platforms. ### `security` is about what is already installed The `security` list is checked against plugins a host **already has installed**, not against the catalog. Removing an entry from `plugins` stops new installs, but does nothing for the people who installed it last month. A revocation is how you reach those machines. Each `versions` string must parse as a Rust `semver::VersionReq` — for example `<0.3.2`, or `>=1.0.0, <1.2.0`. The validator implements that grammar and rejects anything the host could not parse. It also refuses a bare `*`, which would revoke every version a package ever had, and refuses a revocation that matches a version this same index still pins. --- ## Submitting a plugin 1. **Publish to a registry with a scoped package name.** Scoped is mandatory: `@yourscope/plugin-name`. The scope is what maps an entry to its registry, so an unscoped name has nowhere to resolve from. Unscoped names are rejected by the validator. 2. **Get the integrity hash from the registry.** Not from a local build — from whatever the registry actually serves: ```sh curl -sS "https://git.unom.io/api/packages/unom/npm/@yourscope%2fplugin-name" \ | python3 -c "import json,sys; print(json.load(sys.stdin)['versions']['1.0.0']['dist']['integrity'])" ``` 3. **Add your entry to `v1/index.json`** with that exact version and hash, and `verification.reviewedAt` set to the review date. 4. **Run `bun run validate`** and open a pull request. The template is the review checklist; fill it in honestly, including the "anything that gave you pause" field. For a version bump, change `version`, `integrity`, and `reviewedAt`. Nothing else usually needs to move. ### How review works CI proves the pinned bytes are the bytes the registry serves. It cannot tell you what those bytes do. A reviewer therefore diffs the new tarball against the previously pinned one and reads the changes, looking for undisclosed network endpoints, filesystem access beyond the plugin's purpose, obfuscated code, install lifecycle scripts, and unjustified dependencies. The full list is in `.gitea/pull_request_template.md`. Review is against the **published tarball**, not the git repository. The tarball is what users execute; the repo is only evidence about it, and the two can differ. When the pull request merges to `main`, CI validates, signs, self-verifies, and commits `v1/index.json.sig` back to `main`. Hosts pick the new catalog up on their next fetch — see [the signing window](#the-signing-window-and-why-it-fails-closed) for what happens to a host that fetches in the minute before the signature lands. --- ## Signing and key rotation The signature is over the **exact bytes** of `v1/index.json` — no canonicalisation. Any whitespace change invalidates it, which is why the validator enforces one canonical formatting. CI signs automatically from the `INDEX_SIGNING_KEY` secret (a PKCS#8 PEM). The private key exists in exactly one place — that secret — and reaches the signer through an environment variable, never a file in the workspace or a command-line argument. Pull requests run a separate workflow with no access to it, so a fork PR cannot get anything signed. The public key currently pinned in the host: ``` ed25519:V7KKMg8sq2A2TW7D/GFWaM0ruAvigpld9r93JdWcQHw= ``` `bun run verify` defaults to this key, so anyone can audit a published index with no arguments and no setup: ```sh bun run verify ``` The base64 payload is the **raw 32-byte** ed25519 public key — not DER, not PEM. That is the form the host pins. ### Rotation The host pins **two key slots**, which makes rotation a rollout rather than a flag day: 1. `bun run keygen` — writes a new PKCS#8 PEM (mode 0600, never printed to stdout) and prints the new public key. 2. Put the new public key in the host's **second** slot, alongside the current one. Ship a host release that trusts both. 3. Replace `INDEX_SIGNING_KEY` with the new private key. New publishes are now signed with the new key, and both old and new hosts accept them. 4. Once enough hosts have the two-slot release, drop the old key from the host and delete its private half. The overlap is what avoids bricking the store for anyone who has not updated. A host that only knows the old key keeps working until step 4, and a host on the new release accepts both. Never skip straight to signing with a key no shipped host pins — every host in the field would reject the catalog at once. If a key is *compromised* rather than merely aged out, you cannot wait for the overlap: ship a host release that trusts only the new key, and treat every index signed by the old one as suspect. --- ## Third-party sources An operator who wants plugins that are not in this index adds their own source: a URL to another `index.json` in this same format, plus the `ed25519:` public key that signs it. The host applies the identical machinery — signature check first, then pinned version and integrity on install. What such a source does **not** get is the verified badge. Its plugins are shown as coming from that source, attributed to it, and unbadged, because unom did not review them. The badge tracks who did the review, not who serves the file. This is the intended escape hatch. You are not limited to what unom has had time to review; you just have to decide for yourself whether to trust the source you are adding, and the host is honest with you about which is which. --- ## How the index is served Straight out of this repository. Gitea serves raw files anonymously over HTTPS, so the committed files *are* the distribution: ``` https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json.sig ``` There is no separate static host and no deploy step. `v1/index.json.sig` is committed to `main` rather than ignored, because it is the published artifact — and keeping it in git history means every signature this index has ever carried is independently auditable, alongside the exact catalog bytes it covered. Only CI writes it. If you sign locally to experiment, move the result to `scratch.sig` (gitignored) so a throwaway signature can never be mistaken for a real one. Until the first publish run has happened, `v1/index.json.sig` does not exist in the repository at all — that is expected, not a missing file. Two details this arrangement depends on, both verified: - Gitea serves raw files **anonymously**, so a host needs no credentials. - The raw endpoint serves the blob **byte for byte**, with no rewriting. The signature covers exact bytes, so this is load-bearing; `.gitattributes` pins LF line endings to keep a Windows checkout from breaking it from the other direction. ### The signing window, and why it fails closed CI signs **after** the merge. The sequence on a normal release is: 1. A pull request updating `v1/index.json` is merged to `main`. 2. The publish workflow starts: validate, sign, self-verify. 3. It commits `v1/index.json.sig` back to `main`. Between steps 1 and 3 — roughly a minute — the raw URLs serve a **new `index.json` against the previous `.sig`**. The signature does not cover those bytes, so it does not verify. That window fails closed, by design. A host fetching mid-window rejects the document, keeps serving its last known-good cached catalog, and marks it stale. Nobody sees a partially-trusted or unverified entry; they see yesterday's catalog for another minute. The cost is a brief unavailability of *new* entries, not a weakening of the trust model — which is the correct direction for the trade to fall. Two consequences worth internalising: - **Do not hand-edit `v1/index.json` on `main`.** Every direct push opens the window again until CI catches up. Go through a pull request. - **A verification failure right after a merge is expected**, not an incident. Check whether the publish workflow has finished before investigating. If the window ever becomes a real problem, the fix is to sign in the merge pipeline rather than after it, or to publish both files atomically to a static host. Neither is needed at this scale. ### Moving to a dedicated static host later If this repo ever becomes the wrong place to serve from — traffic, availability, wanting atomic two-file publishes — the migration is small and contained. The host pins the index URL as a single constant; pointing it at a new origin is a one-constant change plus a host release. Nothing about the format, the signing, the key slots, or the review process changes, because none of them depend on where the bytes are served. The signature is over the document, not over its location.