The mutating side of the unified distribution primitive (spec §8.3). Hotfixes
can now be applied and reverted, first-party, through the task system.
New scripts/cli/commands/artifact/cli_artifact_apply.sh:
- artifactApply <id>: resolve+gate (applies_when / min_lp / max_lp /
max_footprint / publishers-map role) → fetch+verify payload (sha256 pinned by
the signed index + minisig) → dry-precheck ALL ops (all-or-nothing) → best-
effort snapshot → apply each op recording a precise inverse → bring app up →
auto-rollback (replay undo LIFO, snapshot fallback) → applied-record + History.
- artifactRevert <id>: replay the applied-record's undo log (LIFO).
- Bounded, CLOSED op vocabulary (no run-script/exec, ever): set-config-key,
set-compose-image, patch-file-if-checksum-matches, set-data-file. An
unsupported op rejects the whole artifact at precheck (fail-closed).
- Write-target firewall: scope:app → containers/<app>/ only; scope:system →
configs/ only; the install tree (our code) is off-limits to hotfixes (fork 1).
Drift guards (expect_current / checksum) skip cleanly rather than clobber.
- Two-tier trust: index minisig-verified vs the footprint key (lpFetchIndex)
covers the envelope; payload sha256-pinned + minisig-verified; publishers-map
role gate (a non-official publisher can't claim official). Community per-
artifact-key sigs are gated off until that tier is enabled.
cli_artifact_commands.sh: apply/revert via the task system (artifact_apply /
artifact_revert types — no allowlist needed), + read-only `applied` list.
cli_updater_commands.sh:
- FIX verified safety bug: updaterApplyApp/RollbackApp called `libreportal backup
app "$app"` and `... restore latest`, which parse the app name as the ACTION,
hit the dispatcher's `*)` default (exits 0) — so updates ran with NO snapshot
and rollback was a silent no-op. Call backupAppStart / restoreAppStart directly.
- FIX updaterRecordHistory jq-silent-skip: was `command -v jq || return 0`
(silently dropped the audit entry). Now fail-closed with a brace-agnostic
bash-native prepend fallback; extended with artifact_id/serial/undo_id.
fetch.sh: add _lpJsonEsc (shared JSON-escape for the jq-free fallbacks).
Regenerated source arrays + lazy-load manifest for the new file/functions.
Unit-tested 31/31: every op apply+precheck+undo round-trip, the path-allowlist
firewall (incl. .. traversal + install-tree + cross-app rejection), all-or-
nothing abort, unsupported-op rejection, and the History bash-native fallback
(records + preserves prior entries without jq). A full signed-apply e2e needs
minisign + the signing key (Phase 5 make_hotfix.sh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The four-lens design panel finished (marketplace-first ranked top) and
confirmed the format; graft in the strongest refinements it surfaced so
the spec is genuinely "done":
- Publishers MAP trust anchor: `publisher` is now a key into an index-root
`publishers` map ({display, role, key}) the team-signed index vouches
for, not an inline {name,trust}. An artifact's claimed trust is honored
only if the publisher's role permits AND its sig verifies against that
key — so a community key can never self-certify as official. This is the
load-bearing trust mechanism for the marketplace seam.
- Two-tier reversibility: a per-op `undo` array (precise revert) plus the
snapshot (dirty-op fallback).
- All-or-nothing dry-precheck-all before any snapshot; unknown op rejects
the whole artifact at validation.
- Canonical-bytes signing rule (sign the exact artifact bytes, never
re-serialize on the box) + warrant-canary countersigning index_serial.
- Op vocabulary grown to the full set (set-data-file as the bridge to
bundles; set/unset-compose-env; ensure-compose-up/restart-service).
- Envelope gains version/supersedes/reversible + richer applies_when
(image_match/requires/conflicts).
- CFG_HOTFIX_AUTO + staged rollout / randomized delay / recall-via-supersedes.
- Flag the VERIFIED existing bug: updaterRecordHistory silently skips the
audit entry when jq is absent (cli_updater_commands.sh:154-168) — Phase 2
must make it fail-closed; "nothing silent" depends on it.
- Phases re-sequenced (P2 heart, P3 auto-apply, P4 WebUI, P5 make_hotfix.sh,
deferred registry).
Spec-only change — no code; the Phase 1 read primitive is unaffected (it's
a generic verified fetch; publisher/envelope internals are Phase 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
curl's raw "(6) Could not resolve host" / 404 noise leaked through on
the index.json download while the .minisig fetch was already silenced —
inconsistent and confusing. The caller's clean isError covers the
failure, so route the index download's stderr to /dev/null too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Build the read side of the unified distribution primitive from
docs/roadmap/updates-and-distribution.md: one team-signed catalog
(index.json) on the same channel as latest.json, listing type-tagged
artifact envelopes. A hotfix is the first artifact type; apps/themes/
components are future envelope rows through the SAME pipe — the
marketplace seam is just the `type` + `payload.kind` fields.
Phase 1 is fetch + verify + parse only (NO mutation; the snapshot →
ops → rollback → History apply verb is Phase 2):
- Factor `lpVerifyMinisig` out of `lpFetchRelease` (scripts/source/
fetch.sh) — one trust anchor (the root-owned footprint key) now
shared by releases and the index; refactor `lpFetchRelease` to use
it (behaviour-preserving, still fail-closed).
- scripts/source/artifacts.sh: `lpFetchIndex` — download →
verify-before-parse → `valid_until` freshness (anti-withholding) →
`index_serial` monotonic high-water (anti-rollback, TUF-lite) → emit
verified JSON. Trust core is jq-free; parsing accessors prefer jq
with a grep fallback.
- `libreportal artifact index` (scripts/cli/commands/artifact/) —
read-only front door that fetches, verifies and lists. Runs directly
like `updater check` (no task; no mutation).
- Regenerate the source arrays + lazy-load function manifest for the
new files.
Doc: promote the format from vision to spec (§8) — 3 layers
(INDEX/ENVELOPE/PIPELINE), the bounded declarative op vocabulary (no
run-script, ever), the apply pipeline mapped onto existing functions,
the marketplace seam, and resolutions for all five open forks.
Self-tested 12/12: trust core fails closed (real key + no minisign →
refuse), happy path, stale-refused, rollback-refused, signature-refused,
jq + grep parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The teardown audit found the backup-stacking leak class across 4 more feature
modules (12 confirmed leaks); unmount() left document/window listeners, intervals,
and SSE subscriptions firing on stale controllers after navigation:
- admin: overview/ssh/peers/system each leaked a document click listener ->
AbortController + dispose() per page; admin unmount() aborts each.
- dashboard: the 1 Hz update-countdown interval + the LiveSystem view sub ->
stopUpdateCountdown()/detachDashboardLive(), registered via ctx.sub().
- tasks: constructor-started global live-log poller (discarded handle) -> stored
+ idempotent + cleared on unmount + re-armed on mount; per-task monitorTask
window listeners + interval -> tracked in a map, released on unmount.
- apps: app-tabbed reconcile setTimeout loop + watchdog window/document listeners
+ popstate -> per-instance AbortController + dispose() that clears the timer,
resets the guards, and unloads the active tab's Services intervals + log SSE.
All mirror the kernel's MountContext teardown discipline. 12 files, all pass
node --check. Backup (fixed earlier) re-confirmed clean by the audit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The original report: clicking a backup sidebar tab loaded content on top of
the old content. Root cause (flagged in the unmount comment as deferred):
BackupPage.bindEvents() attaches document-level click/input/change listeners
guarded only by the instance-level this.eventBound, and unmount() nulled
window.backupPage WITHOUT removing them. Each revisit added another full set of
listeners bound to a stale BackupPage, all firing on every click and mutating
the live DOM (double tab-switches, double modal opens, stale-instance renders).
Fix (mirrors the kernel's MountContext pattern): give BackupPage an
AbortController, bind the three document listeners to its signal, add dispose()
that aborts them (+ drops the task-refresh reg + clears the timer), and call it
from the feature module's unmount(). Revisits now start clean — one live
instance, one set of listeners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The honest-checkSuccess + masking fixes immediately surfaced a real masked
failure in error_report.log: updateDockerSudoPassword (run every system scan
from start_scan.sh) does 'sudo passwd $sudo_user_name', but Model A's scoped
sudoers grants only LP_HELPERS/LP_SYSTEM + run-as-install-user — not passwd.
So at runtime (manager, non-root) it failed exit 1 every scan, masked until now.
The password is set at install (root, chpasswd) and admin login is key-based,
so the runtime re-sync is legacy + impossible under de-sudo: guard it to skip
unless EUID 0. (Validates the surfacing mechanism working as intended.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
'local result=$(cmd)' resets $? to 0 (the local builtin's own exit), so the
following checkSuccess always saw success regardless of cmd's real exit — the
mechanism that masked the de-sudo write failures. Split declaration from
assignment ('local result; result=$(cmd)') across all 235 active-code sites
(84 files) so the command's exit reaches checkSuccess. No behaviour change
beyond $? now being accurate (no set -e in runtime code; multi-line
assignments transform safely).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
checkSuccess silently reported '✓ Success' for failed commands, which is how
the de-sudo write gaps (throttle stamp, passwords, updater) hid. Rework it:
- Capture the real exit code up front; success path unchanged.
- On failure, ALWAYS append to a greppable $logs_dir/error_report.log tagged
with the caller's script:line + exit code — a failure can't hide behind a
green check anymore.
- New CFG_REQUIREMENT_CONTINUE_ON_ERROR (default true): log + continue so one
failure doesn't abort the run and we surface EVERY issue in a single pass.
Flip it off later for strict abort/prompt (the prior behaviour, preserved).
Documents the 'local VAR=$(cmd); checkSuccess' footgun (local resets $?), which
the next commit fixes across the tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
updater_check/apply/apply_all/rollback tasks fell through every per-type
branch of the Tasks panel, so they showed the generic custom gear icon, a
raw/truncated command title, and (for the app:'updater' sentinel) a broken
hidden app icon. Wired them in like every other task type:
- tasks-format.js formatCommandForUser PATTERNS: added the 'libreportal updater'
command rows (Apps - Check for Updates / Update All / <App> - Update /
<App> - Roll Back) — only the *self*-update 'libreportal update' was mapped.
- tasks-format.js formatActionTitle: added the updater_* short labels.
- tasks-list-render.js getTaskTypeIcon: updater_check 🔍 / apply ⬆️ /
apply_all ⬆️ / rollback ↩️ (reusing existing verify/update/restore classes).
- tasks-list-render.js renderTaskIcons: treat app:'updater' as a sentinel like
app:'system' so updater_check/apply_all fall back to the LibrePortal logo
instead of a 404'd /core/icons/apps/updater.svg (apply/rollback keep their
real app icon).
node --check clean.
Signed-off-by: librelad <librelad@digitalangels.vip>
Two more cases of the manager writing directly into the container-owned
/libreportal-containers tree (same class as the regen-poll stamp), both masked
by a '✓ Success' that printed anyway:
- Password replacers (config/password/*): used 'runInstallOp sed -i' (manager)
on app configs copied into the container tree, so sed -i EACCES'd its temp
file and the substitution silently failed — the adguard.config 'couldn't open
temporary file', leaving the literal RANDOMIZEDPASSWORD placeholder. Added
runCfgOp (picks runFileOp vs runInstallOp by the target file's location) and
routed every $file grep/sed/awk through it: password, username, hex, vapid,
appkey, and bcrypt.
- Updater generator (webui_updater_scan): 'runFileOp cp <manager-tmp>' can't
read the manager's 0600 mktemp as the container user, so it fell through to a
manager 'cp' that EACCES'd on the container-owned out_dir. Switched the three
writes to 'runFileWrite < tmp' (manager shell reads the tmp; container user
tees the write).
Both deploy via the normal quick path (relocatable scripts) — no footprint bump,
no reinstall.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A self-referential array — files_source.sh enumerates the arrays/ files — only
picks up a newly-created arrays/ file on the next regen pass. The task-folder
move created files_task.sh; this pass adds it to source_scripts so the committed
arrays match a fresh regen (and make_release's stale-array guard stays happy).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
maybeRegenPoll truncates $REGEN_POLL_STAMP (.regen_poll_at) to throttle the
self-heal 'regen webui' poll, but the stamp lives in the docker-install-owned
TASK_DIR — the manager-run processor can't write there, so the truncate
EACCES'd every poll (swallowed by || true). The stamp never updated, so the
throttle read last=0 forever and 'regen webui' ran on every idle tick (and
spammed the journal ~16x/min).
Fix: pre-create the stamp world-writable in setupTaskDir, exactly like the
lock file and FIFO already are (runFileOp install -m 666). Truncate then
lands, the mtime advances, and the poll throttles to REGEN_POLL_INTERVAL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The task processor is a systemd-service daemon, not a cron job — move it out
of the misleadingly-named scripts/crontab/task/ to scripts/task/.
To stop the systemd unit from baking the processor's in-tree path (the footprint
coupling that forces a reinstall on every reorg), the unit now ExecStarts the
stable wrapper: /usr/local/bin/libreportal __task-processor. start.sh intercepts
that early (after paths.sh, before the heavy load), exports install_scripts_dir,
and exec's the processor with start_script. Future moves/renames need only the
one hand-off updated + a regen — no footprint bump.
- git mv scripts/crontab/task -> scripts/task (filenames kept; cron-watchdog grep
+ function names unchanged)
- libreportal-svc: ExecStart -> stable wrapper launcher
- start.sh: __task-processor internal launcher (export install_scripts_dir; exec)
- crontab_task_processor.sh: fix self-location ../.. -> .. for the new 1-level
depth (latent bug the move would otherwise have introduced)
- regen files_*/function_manifest; add task_scripts to the app/cli aggregates
- footprint_version 3 -> 4 (root-owned svc unit changed -> needs a root reinstall)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Verified-dead assets from the feng-shui audit, zero consumers:
- core/icons/categories/utils.svg — no 'utils' app category exists (the only
'utils' refs are unrelated system health-check names); category icons are
requested as /core/icons/categories/<id>.svg and no id is 'utils'.
- core/icons/apps/portainer.svg — Portainer was retired to
scripts/unused/OLD_CONTAINERS/; no live containers/portainer/, and apps.json
is generated only from live containers, so the icon is never requested.
Both git-recoverable if a portainer app / utils category is ever (re)added.
Signed-off-by: librelad <librelad@digitalangels.vip>
The shipped frontend carried ~600 muted '// console.…' debug statements (and
their multi-line commented continuation lines) left over from development —
clutter across 30 files. Removed them with a guarded pass that ONLY ever deletes
lines starting with // (so it can never alter behaviour), consuming each
commented console opener plus its continuation comment lines until the
string-stripped parens balance.
665 lines removed, 30 files; 0 insertions. Verified every deleted line is a //
comment (no code touched), real prose comments preserved, full node --check
sweep clean.
Signed-off-by: librelad <librelad@digitalangels.vip>
From the feng-shui audit naming findings:
- admin/overview/js/admin-overview.js -> overview-page.js (class AdminOverview ->
OverviewPage, window globals + the 'admin-overview' task-refresh id ->
overview-page, lazy-load path + typeof/new in config-manager.js).
- admin/system/js/admin-system.js -> system-page.js (class AdminSystem ->
SystemPage; now sits beside its -page sub-views system-metric-page.js /
system-storage-page.js).
- tasks/js/tasks-logs-modal.js -> tasks-log-modal.js (singular 'log' to match its
sibling tasks-log-stream.js; single path ref in system-loader.js).
These were the only page controllers breaking the dominant <thing>-page.js /
<Thing>Page convention (ssh-page/peers-page/backup-page/updater-page/
system-metric-page/system-storage-page). Pure renames; node --check clean.
Signed-off-by: librelad <librelad@digitalangels.vip>
- docs: remove the docs/README.md index and docs/CONTRIBUTING.md pointer
(duplicate filenames); the canonical contributing guide stays at
docs/contributing/contributing.md. Clean tree, no name collisions.
- scripts/system/*: 6 helper headers + host_access.sh said the helpers
install to /usr/local/sbin, but init.sh installs all of them to
/usr/local/lib/libreportal/ (verified via initRootHelpers + the sudoers
Cmnd_Alias). Corrected. The only remaining /usr/local/sbin is the legit
PATH export in the task processor.
- frontend kernel: drop migration-era comments that are now false post-
modularization (feature-registry 'passive/phase 0/unused', lifecycle
'ctx.services lands with Phase 2', manifest 'scan generator lands') —
describe current behaviour instead.
Comment-only edits to scripts/system/* — no footprint_version bump (no
behavioural change; bumping would force needless reinstalls).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Audited every doc against the code. Three fixes:
- system-footprint.md: add the libreportal-crowdsec root helper row
(init.sh installs 8 helpers; the table listed 7). appcfg row clarified
to 'CrowdSec-bouncer' config since the new helper does the host install.
- .gitattributes: add 'site export-ignore' — development.md documents the
website as never-shipping, but the rule was missing, so site/ was landing
in release tarballs. No runtime refs to site/; hosting lives in the Infra repo.
- promise.md: fix LICENSE link (../../LICENSE) after the docs/ reshuffle.
Everything else (install-and-use, development, contributing) verified current:
all install/uninstall/update flags, release scripts, fetch fns, footprint_version,
service name, and config keys check out against the code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Sort docs/ into guide/ contributing/ architecture/ roadmap/ and rename
to consistent kebab-case (USER->guide/install-and-use, FOOTPRINT->
architecture/system-footprint, frontend-modularization->architecture/
webui-architecture, etc.). Add a docs/README.md index and a docs/
CONTRIBUTING.md pointer so the forge still surfaces the contributing
guide. Fix every reference (README, init.sh comments, frontend code
comments, and the USER<->DEVELOPMENT cross-links). History preserved
via git mv. Root stays README.md + CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Comment-only tidy from the feng-shui audit — no code behavior changes. The
features/ directory was renamed to components/ during modularization, but
several header banners and inline comments still named the old path:
- 6 component module headers (admin/tasks/backup/dashboard/updater/index.js +
updater/js/updater-page.js) now name their real components/<id>/… path
- core/kernel/js/spa.js + core/tasks/js/task-router.js comments
- backend/routes/features.js doc-banner (drop a components/<id>/ folder …)
- core/update-notifier/css/update-notifier.css header (js/update-notifier.js)
Guarded the rewrite so the LIVE /api/features/list endpoint name (feature-
registry.js sources + backend route) is untouched — only stale 'features/<path>'
directory references were updated.
Signed-off-by: librelad <librelad@digitalangels.vip>
The modularization shipped (2026-05-30), so the design doc was stale and
internally contradictory: it described a features/ tree (real tree is
components/), a shell-generator/Node route that were never built, and a
'partially implemented' status. Replace the 59KB design exploration with
a short, accurate description of the component-module system as it exists
(components/<id> pages, core/ subsystems, the kernel: feature-registry/
services/lifecycle/spa, static manifest discovery, mount/unmount contract,
eager global CSS). Fix one stale features/ path in a spa.js comment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
From the feng-shui audit (all adversarially verified):
- BUG (high): apps-grid.js category tiles used onerror fallback
/core/icons/categories/default.svg, which doesn't exist (the dir has
misc.svg as its generic icon, which data-loader.js already uses). Any
category missing its named SVG showed a broken-image glyph. Repointed to
/core/icons/categories/misc.svg.
- TIDY: core/forms was the lone depth-3 nesting — JS at forms/controls/js/
while its CSS sat at forms/css/ and every other core subsystem uses
<name>/js/. 'controls/' grouped nothing (just the 2 custom-* widgets), so
flattened to core/forms/js/ (+ updated index.html). forms is now symmetric.
- CONSISTENCY: components/manifest.dev.json entries carried nav.order but not
the top-level 'order' that each feature.json has; added it so the API-down
fallback matches the live /api/features/list scan.
Signed-off-by: librelad <librelad@digitalangels.vip>
Captures the brainstorm on hotfixes, the updater reframe to
'Updates & Improvements', and registry-not-marketplace distribution:
one signed/declarative/reversible primitive behind hotfixes, app
installs, and themes. Vision/TODO doc with open forks, not a spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>