LibrePortal/docs/roadmap/updates-and-distribution.md
librelad 9ca5e8922c docs(distribution): mark the hotfix product (Phases 1–5) built
Update §8.7 + the banner + §1 TODOs to reflect that Phases 2–5 shipped today
(apply/revert pipeline, severity-split auto-apply, the WebUI Improvements stream
+ per-app chip, and make_hotfix.sh). Only the registry/marketplace stays
deferred (demand-gated by design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 21:26:31 +01:00

457 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LibrePortal — Updates, Improvements & Distribution (Roadmap / Vision)
**Status:** §0§7 are the brainstorm (vision). **§8 is the committed format spec** and the open forks (§6) are resolved there. · **Audience:** us, future-self · **Scope:** the updater feature, "hotfixes", and how third-party themes/apps/components get distributed · **Origin:** brainstorm 2026-05-30/31 → format decided & the hotfix product (Phases 15) built 2026-05-31
> Sections 07 below are the original thinking doc — kept verbatim so the
> reasoning isn't lost. **The conclusion of that brainstorm is §8: the concrete
> artifact format**, designed so apps/themes/components slot into the same pipe a
> hotfix uses. The hotfix product (Phases 15: signed fetch+verify, the reversible
> apply/revert pipeline, severity-split auto-apply, the WebUI Improvements stream,
> and the `make_hotfix.sh` publisher tool) is **built** — see §8.7. Only the
> registry/marketplace is deferred. The forks in §6 are no longer open; §8.5
> records how each was resolved.
---
## 0. The one idea everything hangs off
The cohesion worry that started this: the updater feels like a **bolt-on**. The fix
isn't to hide it — it's to notice that hotfixes, app updates, themes, and components
are all the *same verb*:
> **LibrePortal pulls a signed, declarative thing from a source, verifies it, and
> applies it reversibly (snapshot → apply → rollback).**
Build that **one distribution primitive** once, and hotfixes / app-installs / themes /
components become three *payloads* through one pipe — not three separate features.
That single primitive is the spine of this whole doc.
It rides machinery that already exists:
- **Mutations via tasks** — every apply is a `libreportal …` task, never a new mutating API.
- **Scan-and-manifest** — a thing is "installed" by dropping a folder; the scan discovers it.
- **Recovery** — the updater already snapshots-before-update and can roll back. Everything inherits that safety net for free. *This is what makes bold defaults defensible.*
- **minisign** — release signing infra already exists; reuse it as the trust anchor.
- **The existing update-check pipe** — already pings out for "is there a new version"; extend that *one signed manifest*, don't add a second phone-home.
---
## 1. Hotfixes
**What it is:** a small, signed, individually-reversible, **declarative** change the
LibrePortal team ships *out-of-band* (between releases), each with a plain-English
what + why, each independently toggleable.
**The killer use case — upstream breakage.** Self-hosters get burned independently when
an upstream image changes something (Vaultwarden renames an env var, Jellyfin moves a
data dir, an app's `latest` tag breaks on a Tuesday). A hotfix channel turns the team's
collective firefighting into a shipped product: *we notice, push a one-line reversible
fix, it lands on every install within hours.* No single self-hoster can replicate that.
**Content flavors:**
- **Upstream-breakage fixes** (the killer one)
- **Security hardening** (tighten a default header, disable a risky default)
- **Compatibility shims** (ARM, rootless, specific kernels)
- **Quality-of-life tweaks** ("cool tweaks we found useful")
**The supply-chain contract (non-negotiable for this project):** an on-by-default,
auto-fetched, auto-applied feed *is* a remote-code channel into every box. So:
- **Signed** — minisign, our key.
- **Declarative, not arbitrary scripts** — "set config key K", "add compose label L",
"patch file F *only if its checksum matches*". Bounded + auditable, not `run this .sh`.
- **Public + identical for everyone** — same transparency model as the warrant canary.
A publicly-logged feed makes a *targeted* hotfix to one victim impossible to send silently.
- **Rides the existing update-check pipe** — no new phone-home, no new metadata leak.
- **Nothing silent** — every applied hotfix lands in **History** with what / why / revert.
**On "enabled by default" (UNDECIDED — see open forks):** leaning toward splitting by
severity — *security/breakage* auto-applies (rollback has your back); *tweaks/QoL* are
surfaced with one-click apply, or auto only if the user opted into "auto-improve."
**Why on-by-default is even defensible:** because Recovery already exists — every hotfix
is reversible through the same task → snapshot → apply path. The safety net unlocks the
bold default.
`TODO` (when prioritized):
- [x] Define the declarative hotfix schema (the allowed operations + checksum preconditions). → **§8.2**
- [x] Decide auto-apply policy (uniform vs severity-split). → **§8.5 fork 2** (severity-split)
- [x] Fetch + verify the signed manifest on the same channel as the version check. → **§8.7 Phase 1 (built)**
- [x] Apply pipeline for the ops (snapshot → apply → verify → rollback → History). → **§8.7 Phase 2 (built)**
- [x] Surface applied/available hotfixes as a stream in the updater + History audit trail. → **§8.7 Phase 4 (built)**
---
## 2. Reframe the updater → "Updates & Improvements"
The updater's identity is currently fuzzy ("a list of app versions" — which honestly
*could* just be a tab on the app page, which is why it reads as bolted-on). Hotfixes give
it a reason to be its own thing. Rename the concept from **"App Updater"** to
**"Updates & Improvements"** — the single front door for *everything that changes your
install from the outside*:
- **App updates** (version bumps)
- **Security** (CVEs — the urgent stuff)
- **Hotfixes** (curated small improvements — §1)
- **Recovery** (the safety net that makes all of it safe to apply)
- **History** (audit trail of everything applied)
That earns the standalone link and answers the earlier "should this fold into Admin / be
a tab on apps?" question: it stays its own section *because* it's now the curated-improvement
channel, not just a version list. (Existing tabs already are Overview / Updates / Security /
Recovery / History — this is mostly a framing + the hotfix stream, not a rebuild.)
`TODO`:
- [ ] Decide on the rename / framing in the UI.
- [ ] Add the Hotfixes stream as a tab or a section within Overview.
---
## 3. Distribution: a **registry**, not a **marketplace**
For getting third-party **apps / components / themes** onto a box: do **not** build an
upload platform (the Google-Play / Nextcloud-store / npm shape = hosting + accounts +
moderation + liability for code running near-root on people's boxes). That's the
worst-fitting shape for a privacy/no-managed-hosting/blind-relay project.
**Want Nextcloud's *UX* (in-app browse + one-click install) on F-Droid's *backend*** (a
signed, git-published index of recipes pointing at authors' own repos; contribution = a PR
to the index repo; you host a static signed JSON, not an upload server). Power users can
add a **custom source URL** (a "tap"), so the ecosystem is open without you being the host
or gatekeeper.
### 3.1 Why our apps aren't Nextcloud's apps (the key insight)
A **Nextcloud app** is a PHP plugin running *inside* the Nextcloud process — it can do
anything, which is why Nextcloud needs a code-signing CA + review. A **LibrePortal app**
is a *whole separate container we orchestrate* (upstream's image, from upstream's
registry). What a user "adds" is a **definition** (image, ports, config keys, routing) —
*wiring*, not in-process code. That's a much smaller, more declarative trust surface.
Lean into it.
### 3.2 The one real danger to design around
A LibrePortal app definition can ship host-side `tools/*.sh` hooks that run via the task
system. The compose/config is declarative + safe-ish; **the hook scripts are the
arbitrary-code part** (our equivalent of Nextcloud's in-process PHP). So tier trust around
*that*:
| Tier | Signed by | Host scripts | UI |
|---|---|---|---|
| **Official** | LibrePortal team key | allowed (reviewed) | green check |
| **Community** | author key | disallowed / sandboxed / **shown for review before install** | yellow "community — review the source", extra confirm |
| **Custom source** | author key / unsigned | advanced | "you're on your own" framing |
### 3.3 Install flow (all existing machinery)
Browse catalog → click **Add** → WebUI dispatches a task (`libreportal app add <signed-source>`)
→ fetch definition, verify signature/checksum, drop into `containers/<app>/`, run scan/regen,
app appears. Snapshot-before + reversible uninstall via Recovery. No new mutating API.
`TODO`:
- [ ] Build the signed-fetch + reversible-install primitive (§0) — hotfixes need it too.
- [ ] Surface first-party app definitions as a browsable "Browse & Add" catalog in the App Center.
- [ ] Define the trust tiers + how host scripts are gated for community sources.
- [ ] (later) The signed git index format + "add custom source" UX.
- [ ] (later) Theme gallery on the same index (lowest risk, but still signed — CSS can exfil via `background-image`).
---
## 4. Sequencing — don't build the storefront before there are goods
You have one theme set, a handful of first-party apps, and zero community contributions
today. A registry with nothing in it is pure overhead. So:
1. **First-party catalog UX now** — surface our own app definitions as browse-and-add.
Useful day one with no third parties; first-party apps *are* the seed catalog.
2. **The signed-fetch + reversible-install primitive** underneath (hotfixes need it anyway).
3. **Open to a community index** only once there's real demand. The index is a one-file
signed artifact you add the day the first good community app/theme exists — not a platform.
Same staging applies to hotfixes (first-party only, always) and themes.
---
## 5. Money / Connect note
A *paid* marketplace contradicts the decided Connect direction (blind relay, no managed
hosting; value = privacy relay + support stack). If money ever enters, "curated/supported
components *as part of Connect*" fits the model; "host a store and take a cut" does not.
Flag only — not on the table.
---
## 6. Open forks (RESOLVED — see §8.5)
> These were the genuinely-undecided questions. They are now decided; §8.5 holds
> the resolutions and the reasoning. Kept here for the record.
1. **Hotfix scope** — config/compose tweaks only, or can a hotfix patch app files / our own WebUI code too? (Sets the entire risk profile.)
2. **Auto-apply policy** — uniformly on-by-default, or split by severity (security auto, tweaks surface-and-suggest)?
3. **Hotfix locality** — per-app (also shows on the app's page) vs system-wide vs both?
4. **Third-party contribution — yet?** Or first-party-curated for the foreseeable future? If the latter, skip the index entirely and just build the signed-fetch primitive; "registry" is a v2 concern.
5. **App catalog entry point** — curated Browse-&-Add list, or bring-your-own-compose (add an arbitrary container) as the primary entry, or both?
---
## 7. Stuff we discussed but didn't capture here
*(Placeholder — there were more conclusions from the brainstorm that didn't make it in.
Add them as they resurface.)*
- [ ] _…_
---
# Part II — The format (committed spec)
## 8. The artifact format
This is the concrete shape the brainstorm landed on. It was stress-tested by a
four-lens design pass (marketplace-first, security-first, simplicity/reuse-first,
ops-ux-first) that **converged** on the same model — *marketplace-first* scored
top, with security-first's **publishers-map trust anchor** and ops-ux's **per-op
`undo` array** grafted in. That convergence is why it's promoted from "vision" to
"spec". The whole thing is **one verb over a type-tagged envelope**; a hotfix is
the first artifact type, and apps/themes/components are *new envelope rows*, not
new features.
### 8.0 Three layers (each already half-built)
| Layer | What it is | Reuses |
|---|---|---|
| **INDEX** | A static, team-signed JSON catalog at `$base/$channel/index.json` (+ `.minisig`), in the **same release tree as `latest.json`**. A list of artifact ENVELOPES. | `fetch.sh` downloaders, the footprint signing key, the existing update-check phone-home |
| **ENVELOPE** | One artifact entry. **Fixed** metadata for every type; the *only* type-specific part is `payload`, a tagged union keyed by `payload.kind`. | — (new, but tiny + frozen) |
| **PIPELINE** | The verb: fetch → verify(sha256+sig) → snapshot → apply → verify → auto-rollback → History. | `lpFetchRelease`/`lpVerifyMinisig`, `updaterApplyApp` (snapshot/rollback/History), the task system |
The envelope **never changes** as new types arrive. Only two fields carry the
type information: `type` and `payload.kind`. That is the whole marketplace seam.
### 8.1 The INDEX + ENVELOPE (example)
`get.libreportal.org/stable/index.json` (signed by `index.json.minisig`):
```jsonc
{
"schema": 1,
"index_serial": 17, // monotonic; anti-rollback (TUF-lite)
"valid_until": 1750000000, // epoch; a stale feed is REFUSED (anti-withholding)
"generated_at": "2026-05-31T12:00:00Z",
"publishers": { // TRUST ANCHOR map — the team-signed index vouches for it
"libreportal": { "display": "LibrePortal", "role": "official", "key": "RWR…" }
// future: "alice": { "display": "Alice", "role": "community", "key": "RWS…" }
},
"artifacts": [
{
"id": "hf-vaultwarden-signup-env-2026-05", // stable, unique — the History/snapshot/toggle key
"type": "hotfix", // hotfix | app | theme | component (the dispatch axis)
"version": 1, // monotonic per id
"supersedes": [], // ids this retires (also the recall mechanism)
"reversible": true, // false ⇒ extra confirm before apply
"publisher": "libreportal", // a KEY into index.publishers (NEVER inline)
"trust": "official", // honored only if the publisher's role permits it
"severity": "breakage", // security|breakage|compat|tweak
"auto": true, // see §8.5 fork 2 (severity-split default)
"title": "Fix Vaultwarden signup after upstream env rename",
"why": "Upstream renamed SIGNUPS_ALLOWED; logins break until the new key is set.",
"applies_when": { // the "target" gates; missing = always
"app": "vaultwarden", "min_lp": "1.0.0", "max_lp": null,
"max_footprint": 4, "image_match": null, "requires": [], "conflicts": []
},
"payload": {
"kind": "ops", // ops (hotfix) | bundle (app/theme/component)
"url": "stable/payloads/hf-vaultwarden-signup-env-2026-05.json",
"sha256": "…", "sig": "stable/payloads/hf-…json.minisig"
}
}
]
}
```
**Fixed fields, identical for every type:** `id, type, version, supersedes, reversible,
publisher, trust, severity, auto, title, why, applies_when, payload{kind,url,sha256,sig}`.
An app entry is byte-for-byte this shape with `type:"app"`, `payload.kind:"bundle"`, and a
tarball payload. A theme is `type:"theme"`, `kind:"bundle"`. Nothing in the envelope moves.
**`publisher` is a key, never inline.** It points at an entry in the index-root
`publishers` map, which the *team-signed index* vouches for (`{display, role, key}`). An
artifact's claimed `trust` is honored **only if** (a) the referenced publisher's `role`
permits it **and** (b) the artifact's own signature verifies against that publisher's
`key`. Because the manager can't edit the (signed) index, it can't self-promote a key — so
a `community` publisher can **never** masquerade as `official`. This is the load-bearing
trust mechanism for the whole marketplace seam, and it's present day one.
**Forward-compat firewall:** an installed box that doesn't recognise a `type` or a
`payload.kind` **skips + logs** it (never errors). So the registry can publish new
types the day a newer client understands them, without breaking older installs.
### 8.2 The op vocabulary (`payload.kind:"ops"` — the hotfix body)
A **bounded, closed, declarative** set. **There is no `run-script`/`exec`/`shell` op,
ever** — a signed feed is an RCE channel *only if it can carry code*; this is the
supply-chain contract from §1. The payload file is `{ "schema":1, "ops":[ … ] }`. The
applier is a hardcoded dispatch `case`; an **unknown op name rejects the whole artifact at
validation, before any snapshot** (fail-closed, never a partial apply). Every op:
1. is **precondition-guarded** (checksum / `expect_current`) — it refuses (SKIP, recorded)
on local drift rather than clobbering;
2. **two-tier reversible** — each op records a **pre-image into the History `undo` array**,
so a clean op reverts *precisely* without a full restore; the snapshot (always taken) is
the fallback for a dirty/un-invertible op, so even a buggy op can't make rollback wrong;
3. writes **only through the existing privilege funnels**`runInstallOp`/`runFileOp`
by tree (never raw `sudo`); `set-config-key` rides `updateConfigOption`, which already
routes the write correctly per the de-sudo split.
**All-or-nothing**: the applier *dry-prechecks every op first*; if any precondition fails,
the whole artifact is **skipped untouched** (a first-class History entry, so coverage gaps
are visible — risk: a customised box may legitimately miss a fix). At most one
`dockerComposeUp` per app, after all ops.
| op | args | apply (existing fn) | undo | precondition |
|---|---|---|---|---|
| `set-config-key` | `key,value` | `updateConfigOption KEY VALUE``webuiGenerateSystemConfigs` | prior value (or delete if absent) | `key` matches `^CFG_[A-Z0-9_]+$`; opt. `expect_current` |
| `add-compose-label` / `remove-compose-label` | `app,service,label` | edit `containers/<app>/docker-compose.yml` via `runFileOp` | inverse op | service exists |
| `set-compose-image` | `app,service,image,from` | rewrite the `image:` line → `updaterComposePull` | restore `from` | current image == `from` (pin-a-broken-`latest`) |
| `set-compose-env` / `unset-compose-env` | `app,service,key,value` | upsert/remove env entry | restore prior | — |
| `patch-file-if-checksum-matches` | `path,expect_sha256,content_ref,result_sha256` | write new content **iff** current sha256 matches; assert post==result | captured bytes | **hard** sha256 match; allowlisted to `containers/<app>/`; install/WebUI tree **only** `trust:official` + `scope:system` |
| `set-data-file` | `path,url,sha256` | fetch + verify + drop a whole file | captured bytes | path-allowlisted; **the bridge to `bundle`** (a bundle = N of these) |
| `ensure-compose-up` / `restart-service` | `app` \| unit | `dockerComposeUp` / restart (allowlist: the `libreportal` unit, traefik) | no-op | — |
`set-compose-image` + `patch-file-if-checksum-matches` are the upstream-breakage killers
(§1). The checksum lock turns "patch a file" from an arbitrary write into a drift-safe,
conflict-detecting, reversible transform. `set-data-file` is deliberately the seam to the
future `bundle` applier — apps need a *new applier*, not new ops.
### 8.3 The PIPELINE (the verb) — `libreportal artifact apply <id>`
A generalization of `updaterApplyApp`, run **only as a task** (`cliTaskRun "libreportal
artifact apply <id>" "artifact_apply" "<app|->" ""`; the processor re-invokes with
`LIBREPORTAL_TASK_EXEC=1`). Seven steps — **six are type-agnostic; only step 4 dispatches
on `payload.kind`**:
0. **RESOLVE** (read-only) — `lpFetchIndex` (cached), find the envelope by id, check
`applies_when` + `lpVersionGt` + `max_footprint <= lpInstalledFootprintVersion`
(reuse `fetch.sh`'s exact footprint guard). Gate fails → History `skipped` + reason.
1. **FETCH**`_lpDownload "$base/$channel/$payload.url"`.
2. **VERIFY**`_lpSha256` == `payload.sha256`, then `lpVerifyMinisig` against the
per-artifact `payload.sig`. (Two-tier: footprint key signs the index; the index
pins each payload's hash + sig.)
3. **SNAPSHOT**`libreportal backup app <app>` (the Backup engine) — the reversibility
anchor that makes auto-apply defensible.
4. **APPLY**`kind:"ops"` → the §8.2 interpreter; `kind:"bundle"` → drop+scan/regen
(Phase 4). **Only this step knows the type.**
5. **VERIFY** — app healthy / container up (reuse the updater's post-check).
6. **AUTO-ROLLBACK on failure**`updaterRollbackApp <app> auto` (restore the snapshot).
7. **HISTORY**`updaterRecordHistory` (extended with `artifact_id`, `serial`) → the
existing History tab. **Nothing silent.**
### 8.4 The marketplace seam
**Unchanged forever** (built once, reused): the index file + location + bash-native
parser; the whole envelope shape; pipeline steps 03,57; the two-tier trust chain;
mutations-via-tasks; the `valid_until`/`index_serial` guarantees. Adding apps/themes/
components is **purely additive**:
- a new `type` value becomes "handled" in step 4's dispatch (old boxes skip+log — §8.1 firewall);
- those types use `payload.kind:"bundle"` (a signed tarball) + one new bundle handler;
- a **custom source ("tap")** is just a second `(base_url, pubkey)` pair appended to a
list — zero envelope change, the registry opens without us hosting or gatekeeping.
This is exactly the §3 "registry, not marketplace" shape, now expressed in the format.
### 8.5 Fork resolutions (was §6)
1. **Hotfix scope** → **config/compose ops + checksum-pinned file patches; NO code
execution.** `patch-file-if-checksum-matches` is allowlisted to `containers/<app>/` +
`configs/` and is drift-safe + reversible. **Our own install tree (WebUI/CLI code) is
off-limits to hotfixes** — it already has a signed, whole-tree-verified delivery channel
(releases + `SHA256SUMS` + `verify.sh`); letting a hotfix mutate it would open a second,
finer-grained code-injection surface that bypasses the whole-tree signature. Code fixes
ride an edge/out-of-band release. The killer use case (upstream breakage) is 100%
config/compose, so this loses nothing real.
2. **Auto-apply policy****severity-split, declarative in the envelope** (`severity` +
`auto`). `security`/`breakage` → auto-apply ON by default (defensible because the
snapshot/auto-rollback safety net exists); `compat` → surface + one-click; `tweak`
manual unless opted into "auto-improve". A `CFG_HOTFIX_AUTO` toggle
(`security-breakage` default / `all` / `off`) lets the operator tune it; `reversible:false`
always forces a confirm. Blast radius is bounded by per-box snapshot/rollback **plus**
staged rollout (edge before stable), a randomized apply delay, and recall via
`supersedes` (a superseding revert).
3. **Hotfix locality****both.** `applies_when.app` makes an artifact app-scoped (it also
surfaces on that app's page); a null app is system-wide. One field, both behaviours.
4. **Third-party — yet?****first-party only now, registry-ready by design.** The index
ships with one publisher (`libreportal`, role `official`) and one source — no tap UI,
accounts, or moderation. But the `publishers` map + per-artifact `trust` are already in
the envelope, so opening to community is *appending publisher entries / sources and
flipping the trust-gate UI* — not a rebuild. `community`/`custom` tiers gate the riskiest
ops (host-script hooks on `bundle` apps — §3.2).
5. **App catalog entry point****curated Browse-&-Add** (first-party definitions as the
seed catalog), with bring-your-own-compose remaining the advanced/“custom source” path.
### 8.6 Trust & transparency (the non-negotiables, in the format)
- **Two-tier signatures** anchored on the **root-owned footprint key** (`/usr/local/lib/
libreportal/libreportal.pub`) — the manager can't swap it, so it can't bless a forgery.
Tier 1: the footprint key signs the **index** (incl. the `publishers` map). Tier 2: each
artifact's signature is checked against *its publisher's key from that signed map*, and
the claimed `trust` is honored only if the publisher's `role` allows it. A forged inline
key can't self-certify — the trust comes from the team-signed map, not from the artifact.
- **Canonical bytes** — sign/verify the **exact bytes between the artifact's braces** (sig
field excluded); never re-serialize the JSON on the box (a re-serialization mismatch would
silently break every signature). One rule, no ambiguity.
- **`valid_until`** — a signed feed that simply *stops advancing* is the silent-withholding
/ targeting attack; a stale index is **refused**, not treated as "no updates". Same spirit
as the [warrant canary](../../) (freshness = signal); ideally the fortnightly canary
**countersigns the current `index_serial`** so a frozen feed is provable, not just absent.
- **`index_serial`** — monotonic; a lower serial than we've accepted is a rollback attack →
refused. The high-water mark is recorded locally (a dedicated `.index_serial` file, *not*
derived from History — so the anti-rollback guard never depends on History's jq path) and
never lowered by a refused fetch.
- **Public + identical for everyone** — one signed feed; a targeted hotfix to a single
victim is impossible to send without it being publicly visible.
- **Nothing silent** — every apply lands in **History** with what / why / revert. ⚠️ This
guarantee currently has a hole: `updaterRecordHistory` (`cli_updater_commands.sh:154-168`)
does `command -v jq || return 0` — it *silently skips* the audit entry when jq is absent.
Phase 2 must make it **fail-closed with a bash-native fallback** before any hotfix applies.
### 8.7 Build phases & status
- ✅ **Phase 1 — the signed-fetch + verify read primitive (BUILT 2026-05-31).**
- `lpVerifyMinisig` factored out of `lpFetchRelease` (`scripts/source/fetch.sh`) — the
single trust anchor now shared by releases *and* the index; `lpFetchRelease` refactored
to use it (no behaviour change).
- `scripts/source/artifacts.sh`: `lpFetchIndex` (download → **verify-before-parse** →
`valid_until` freshness → `index_serial` anti-rollback high-water → emit verified JSON),
plus parsing accessors (jq when present, grep fallback; the trust core is jq-free).
- `libreportal artifact index` (`scripts/cli/commands/artifact/`) — read-only front door
that fetches + verifies + lists. Runs directly (no mutation), like `updater check`.
- Self-tested: trust core fails closed (real key + no minisign → refuse), happy path,
stale-refused, rollback-refused, signature-refused, jq + grep parsing — 12/12.
- ✅ **Phase 2 — the ops applier + apply/revert verb (BUILT 2026-05-31).** `artifactApply`
/ `artifactRevert` (`cli_artifact_apply.sh`): resolve+gate → fetch+verify payload →
dry-precheck-all → snapshot → apply (each op records a precise `undo`) → bring up →
auto-rollback → applied-record + History. Bounded op vocabulary (`set-config-key`,
`set-compose-image`, `patch-file-if-checksum-matches`, `set-data-file`; unsupported op
rejects the whole artifact). Two-tier trust (index verified vs footprint key + payload
sha256-pinned + minisig + publishers-map role gate). Write-target firewall + value/path
charset guards. Routed as `artifact_apply`/`artifact_revert` tasks. `updaterRecordHistory`
jq-silent-skip **fixed** (fail-closed + bash-native fallback) + extended. The
updater's own broken snapshot/rollback calls fixed too. Hardened against a 17-finding
adversarial security review. Unit-tested 35/35.
- ✅ **Phase 3 — severity-split auto-apply (BUILT 2026-05-31).** `CFG_HOTFIX_AUTO`
(`security-breakage`|`all`|`off`, default `security-breakage`); `webui_artifact_scan.sh`
writes `artifacts_available.json` atomically (keep-prior-on-failure); `artifactApplyAuto`
(`artifact apply-auto`) enqueues eligible signed hotfixes (verified-index-only, in-policy,
applicable, not-applied) from the `updater check`. Unit-tested 13/13.
- ✅ **Phase 4 — WebUI "Improvements" stream + per-app chip (BUILT 2026-05-31).** New
Improvements tab in the updater (severity badges, apply/revert via the task system,
unsigned = apply-disabled) + overview stat card + an amber per-app chip on the App detail
page. Task icons/labels added. Verified visually with `lp-shot`.
- ✅ **Phase 5 — publisher tooling (BUILT 2026-05-31).** `make_hotfix.sh` turns a spec into
the signed payload + index entry (serial bump, freshness, publishers map), minisign-signs
with `LP_MINISIGN_SECKEY`. Verified end-to-end in unsigned/local mode.
- ⬜ **Deferred (registry; additive, demand-gated — intentionally NOT built).**
`payload.kind:"bundle"` applier (verify tarball → extract into the app tree → scan/regen) +
`type:"app"|"theme"|"component"` + the `app_add` task + community trust-tier **host-script
quarantine** (§3.2) + multi-source "tap" UX + the warrant-canary countersigning
`index_serial`. The hotfix product (Phases 15) is complete; the registry waits for demand.