Both entries were stale — the index still pinned rom-manager 0.3.1 and playnite 0.1.1 while the registry had moved on. rom-manager 0.3.2 is a bug-fix release: the API encoded absent optional fields as `null`, which the UI's own decoder then rejected, so the Overview page broke on a never-synced host and the Emulators page's Detect button broke on any box with an emulator installed. The published delta is exactly that fix plus the version bump — no new files, no new outbound hosts. playnite 0.2.0 is a ground-up rewrite onto @punktfunk/plugin-kit: three workspaces, a three-page console UI, per-game include/exclude overrides, and an allow-listed cover-art proxy. Behaviour that matters is preserved verbatim — the ingest inbox is still read first, the art modes are unchanged, and launches still hand back to Playnite. The bundled C# exporter is untouched (same wire schema), so an installed .pext keeps working. Also: playnite's card icon now matches the icon the plugin registers for its own console nav entry (`library-big`), and both descriptions mention the console UI. minHost stays 0.15.0 for both — neither release needs anything newer from the host than the plugin-UI surface and the ingest inbox already provide.
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:
"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. No dependencies beyond Node builtins.
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
{
"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
-
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. -
Get the integrity hash from the registry. Not from a local build — from whatever the registry actually serves:
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'])" -
Add your entry to
v1/index.jsonwith that exact version and hash, andverification.reviewedAtset to the review date. -
Run
bun run validateand 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
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:
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:
bun run keygen— writes a new PKCS#8 PEM (mode 0600, never printed to stdout) and prints the new public key.- Put the new public key in the host's second slot, alongside the current one. Ship a host release that trusts both.
- Replace
INDEX_SIGNING_KEYwith the new private key. New publishes are now signed with the new key, and both old and new hosts accept them. - 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;
.gitattributespins 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:
- A pull request updating
v1/index.jsonis merged tomain. - The publish workflow starts: validate, sign, self-verify.
- It commits
v1/index.json.sigback tomain.
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.jsononmain. 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.