# One catalogue, one set of rules Status: proposed, 2026-09-22. Not implemented. Theseus currently runs two extension systems that happen to produce the same kind of thing. | | Bundled add-ons | Community extensions | |----------------|-------------------------------------|---------------------------------------| | How it arrives | compiled into the build, seeded into the profile on first run | installed from the gateway catalogue | | Who signs | the Silent Mode operator's Ed25519 key, pubkeys compiled into the app | the owner of a BNS name, checked against the chain | | Update channel | `theseus.x/extensions//updates.json` | `theseus.x/extensions/community//updates.json` | | In Settings | a section headed "Built into Theseus" | a separate section headed "Community" | | How to publish | `scripts/sign-addon-update.mjs` + `sia-upload` | the publish page, or `PUT /api/ext/…` | Two catalogues in the UI, two trust rules, two publishing paths — for a difference that is really only "who wrote it" and "is it there on day one". ## What this should be instead **One catalogue.** Every extension is listed in the same place, whoever wrote it. Silent Mode publishes under a Silent Mode name the same way anyone else publishes under theirs. Being first-party is a fact about the publisher, not a separate distribution system, and the UI should say so with a line of text rather than a second list. **One rule for trust.** An extension is installable because a signature checks out against the current owner of the publisher's name. That is already what `verifyPublisherEntry` does; the operator Ed25519 key becomes either one more accepted signer or nothing at all. `pickChannelEntry` already accepts both, so this is mostly deletion. **Defaults are configuration.** Which extensions a fresh Theseus starts with is a list of ids in the app's config, not a folder that gets copied. Changing the default set should be editing that list, not moving directories around and rebuilding. ## Which keys may sign what Multiple signing keys already work, and there is already an allow-list: the `PUBKEYS_HEX` array in `addon-update-pubkeys.js`. `verifySignature` walks it and accepts an update if any entry verifies, so adding a second operator key is a line in that file plus a Theseus release. The file's own comment sets out the rotation: sign with both, ship a release carrying both, then drop the old one. The problem is not that the list is missing. It is that the list is **global**. Every add-on is verified against the same array, so any key on it can sign an update for any id — a key meant for the screenshot tool can push code for the wallet. The signed message is `silentmode.addon-update-v1|||`, which binds a signature to an extension, but nothing binds an extension to a signer. The publisher path does not have this problem. An entry carrying `publisherSig` is checked against the current owner of that extension's name, so the permission is per-extension by construction and rotates on chain without an app release. That is the model to generalise, not replace. So: keep an allow-list, and scope it. - **Per-extension, not global.** Each extension names who may sign it — a BNS name for anything in the catalogue, or a pinned pubkey for a member of the default set. `verifySignature` takes that extension's keys instead of the global array, and its callers look the binding up by id. - **Plural per extension.** Two or three acceptable keys, any one sufficient, so a rotation or a lost key doesn't need an emergency release. - **Prefer the name-owner path for anything new.** The chain is an allow-list that can be corrected the day a key is lost; a compiled-in array can only be corrected by shipping a build. - **Consider co-signing for the default set alone.** Those extensions reach every fresh profile without anyone choosing them, which makes a single compromised key worst exactly there. Requiring two signatures is a bounded amount of work when it applies to a handful of ids rather than to the whole catalogue. The narrowing itself is small — `verifySignature` and its two callers. The work is in deciding where the binding lives and showing it honestly in Settings, so "who is allowed to update this" is something a user can read rather than infer. ## The one thing that needs care If defaults are fetched from the catalogue on first run, a fresh install with no network — or with the gateway down — has no editor, no wallet and no screenshot tool. That is a real regression from today, where the build carries them. So keep shipping the code, but stop treating it as a different kind of thing: - The build continues to include the default extensions' folders. They are a **pre-seeded cache**, not a separate class of add-on. - On first run they are installed from that cache, then verified and updated against the catalogue exactly like anything the user installed by hand. - If a seeded copy fails verification, it is quarantined rather than run — a cache is only a cache. That keeps offline first-run working while leaving one set of rules everywhere else. ## Sketch of the work - **Config**: `defaultExtensions: [{ id, publisher }]` in the app's settings, read at first run and on profile migration. Ids the user has removed are remembered and not re-seeded — uninstalling a default must stick. - **`main.js`**: seeding installs *through* the normal install path instead of copying a folder and calling it built-in. One code path, one place where a signature is checked. - **`settings.html`**: one list. "Installed by default" becomes a badge on a row, next to the publisher's name. The separate Community section and its Install button fold into the single catalogue view. - **`addon-updater.js`**: drop the distinction between the two channel shapes; an extension has a publisher and an updates URL. `verifySignature` takes the keys allowed for *that* id rather than the global array, and `pickChannelEntry` resolves the binding before it verifies anything. - **Gateway**: the catalogue gains first-party entries. Nothing changes structurally — `/api/ext` already stores whatever a name owner signs. - **Publishing**: `scripts/sign-addon-update.mjs` and the operator key stop being the first-party route. Silent Mode's own extensions go through `PUT /api/ext///` like everyone else's, which means the release process needs that name's wallet rather than a key file. ## Open questions - **Which name publishes Silent Mode's extensions?** Whoever goes first owns each id permanently. Worth deciding once, deliberately. - **Pinning.** A default extension is installed without anyone choosing it, so a compromised or coerced catalogue entry reaches every fresh profile. Pinning the default set to a version and a publisher in the app's config bounds that. - **Does the operator key survive at all?** Keeping it as an accepted signer for the default set is a hedge against the name-owner key being lost; keeping it forever means the two-rules problem never fully goes away. - **Where does a per-extension key binding live?** In the manifest, where the extension states its own publisher and an attacker who can rewrite the manifest can restate it; in the app's config, where it is trustworthy but only changes with a release; or on chain, where the name already answers the question. Probably: on chain for the catalogue, pinned in config for the default set, and never simply believed from the manifest. - **What happens to an extension whose signer is no longer allowed?** It should stop updating, but stopping it from *running* is a different and much ruder decision, and the two shouldn't be conflated by accident. - **Does co-signing need a threshold, or just two named keys?** Two keys is simple to implement and to explain; m-of-n is where this quietly turns into key management.